feat: ajouter les factures litiges et leurs commentaires (recette)
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m14s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m14s
This commit is contained in:
@@ -296,6 +296,15 @@ export async function updateInvoice(id: number, data: Partial<Invoice>) {
|
||||
await db.update(invoices).set(data).where(eq(invoices.id, id));
|
||||
}
|
||||
|
||||
/** Les admins consultent tous les litiges ; les autres restent limités à leurs factures. */
|
||||
export async function getDisputedInvoices(userId?: number): Promise<Invoice[]> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
return db.select().from(invoices)
|
||||
.where(userId === undefined ? eq(invoices.isDisputed, 1) : and(eq(invoices.isDisputed, 1), eq(invoices.userId, userId)))
|
||||
.orderBy(desc(invoices.updatedAt), desc(invoices.id));
|
||||
}
|
||||
|
||||
export async function deleteInvoice(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
@@ -3,12 +3,11 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("routage et persistance de déploiement", () => {
|
||||
it("utilise le domaine fourni par l’environnement avec la recette comme repli", () => {
|
||||
it("utilise le domaine canonique recette sans alias historique", () => {
|
||||
const composeFile = readFileSync(join(process.cwd(), "docker-compose.yml"), "utf8");
|
||||
|
||||
expect(composeFile).toContain(
|
||||
"Host(`${APP_DOMAIN:-demat-facturation.recette.santinova-soft.org}`)"
|
||||
);
|
||||
const rule = composeFile.split("\n").find(line => line.includes("traefik.http.routers.demat-facturation.rule="));
|
||||
expect(rule?.split(".rule=")[1]).toBe('Host(`demat-facturation-santinova.recette.santinova-soft.org`)"');
|
||||
expect(composeFile).toContain("source: demat_facturation_storage");
|
||||
expect(composeFile).toContain("target: /app/storage");
|
||||
});
|
||||
|
||||
64
server/disputeNavigation.test.ts
Normal file
64
server/disputeNavigation.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildDisputeDetailLocation,
|
||||
getBapReturnLocation,
|
||||
} from "../client/src/lib/bapNavigation";
|
||||
|
||||
describe("navigation Factures litiges", () => {
|
||||
it("ouvre le détail avec un contexte explicite de retour Litiges", () => {
|
||||
expect(buildDisputeDetailLocation(42)).toBe(
|
||||
"/invoices/42?returnTo=disputes"
|
||||
);
|
||||
expect(getBapReturnLocation("?returnTo=disputes")).toBe(
|
||||
"/invoices-disputes"
|
||||
);
|
||||
});
|
||||
|
||||
it("conserve une recherche fournisseur, numéro ou commentaire avec caractères spéciaux", () => {
|
||||
const location = buildDisputeDetailLocation(
|
||||
18,
|
||||
"Facture été & livraison\nà revoir"
|
||||
);
|
||||
const search = location.slice(location.indexOf("?"));
|
||||
expect(new URLSearchParams(search).get("q")).toBe(
|
||||
"Facture été & livraison\nà revoir"
|
||||
);
|
||||
expect(getBapReturnLocation(search)).toBe(
|
||||
"/invoices-disputes?q=Facture+%C3%A9t%C3%A9+%26+livraison%0A%C3%A0+revoir"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignore les paramètres étrangers au contexte Litiges", () => {
|
||||
expect(
|
||||
getBapReturnLocation(
|
||||
"returnTo=disputes&q=retard&status=exported&year=2026&redirect=https%3A%2F%2Fexample.com"
|
||||
)
|
||||
).toBe("/invoices-disputes?q=retard");
|
||||
});
|
||||
|
||||
it("ne propage pas une recherche vide", () => {
|
||||
expect(getBapReturnLocation("?returnTo=disputes&q=")).toBe(
|
||||
"/invoices-disputes"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuse les destinations de retour arbitraires", () => {
|
||||
expect(getBapReturnLocation("?returnTo=https%3A%2F%2Fexample.com")).toBe(
|
||||
"/invoices"
|
||||
);
|
||||
expect(getBapReturnLocation("?returnTo=%2Finvoices-disputes")).toBe(
|
||||
"/invoices"
|
||||
);
|
||||
expect(getBapReturnLocation("")).toBe("/invoices");
|
||||
});
|
||||
|
||||
it("conserve le retour historique aux factures BAP", () => {
|
||||
expect(
|
||||
getBapReturnLocation(
|
||||
"?returnTo=bap&q=fournisseur&year=2026&month=05&sort=invoiceDate&dir=asc"
|
||||
)
|
||||
).toBe(
|
||||
"/invoices-bap?q=fournisseur&year=2026&month=05&sort=invoiceDate&dir=asc"
|
||||
);
|
||||
});
|
||||
});
|
||||
55
server/invoiceDisputes.test.ts
Normal file
55
server/invoiceDisputes.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
const db = vi.hoisted(() => ({ getDisputedInvoices: vi.fn(), getInvoiceById: vi.fn(), updateInvoice: vi.fn() }));
|
||||
vi.mock("./db", () => db);
|
||||
import { listDisputes, setDispute } from "./invoiceDisputes";
|
||||
import { router } from "./_core/trpc";
|
||||
const routes = router({ listDisputes, setDispute });
|
||||
function caller(role = "user", id = 2) {
|
||||
return routes.createCaller({ user: { id, role }, req: {}, res: {} } as TrpcContext);
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.getInvoiceById.mockResolvedValue({ id: 10, userId: 2, isDisputed: 0, disputeComment: "existant", bapValidated: 1, exportStatus: "exported" });
|
||||
db.getDisputedInvoices.mockResolvedValue([]);
|
||||
});
|
||||
describe("Factures litiges", () => {
|
||||
it("ajoute le litige et son commentaire sans modifier BAP, score ou export", async () => {
|
||||
await caller().setDispute({ id: 10, isDisputed: true, comment: " À vérifier " });
|
||||
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 1, disputeComment: "À vérifier" });
|
||||
});
|
||||
it("modifie le commentaire", async () => {
|
||||
await caller().setDispute({ id: 10, isDisputed: true, comment: "Nouveau commentaire" });
|
||||
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 1, disputeComment: "Nouveau commentaire" });
|
||||
});
|
||||
it.each([null, "", " "])("efface le commentaire avec %s sans retirer le litige", async comment => {
|
||||
await caller().setDispute({ id: 10, isDisputed: true, comment });
|
||||
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 1, disputeComment: null });
|
||||
});
|
||||
it("retire le litige sans supprimer la facture ni son commentaire", async () => {
|
||||
await caller().setDispute({ id: 10, isDisputed: false });
|
||||
expect(db.updateInvoice).toHaveBeenCalledWith(10, { isDisputed: 0 });
|
||||
});
|
||||
it("empêche l’accès à une facture d’un autre utilisateur", async () => {
|
||||
await expect(caller("user", 3).setDispute({ id: 10, isDisputed: true })).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(db.updateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
it("autorise l’administrateur sur les factures globales", async () => {
|
||||
await caller("admin", 3).setDispute({ id: 10, isDisputed: true });
|
||||
expect(db.updateInvoice).toHaveBeenCalledOnce();
|
||||
await caller("admin", 3).listDisputes();
|
||||
expect(db.getDisputedInvoices).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
it("limite la liste aux factures de l’utilisateur", async () => {
|
||||
await caller().listDisputes();
|
||||
expect(db.getDisputedInvoices).toHaveBeenCalledWith(2);
|
||||
});
|
||||
it("interdit la modification en lecture seule", async () => {
|
||||
await expect(caller("readonly").setDispute({ id: 10, isDisputed: true })).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(db.updateInvoice).not.toHaveBeenCalled();
|
||||
});
|
||||
it("exige une session et limite les commentaires à 5000 caractères", async () => {
|
||||
await expect(routes.createCaller({ user: null } as TrpcContext).listDisputes()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
await expect(caller().setDispute({ id: 10, isDisputed: true, comment: "x".repeat(5001) })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
31
server/invoiceDisputes.ts
Normal file
31
server/invoiceDisputes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure } from "./_core/trpc";
|
||||
import { getDisputedInvoices, getInvoiceById, updateInvoice } from "./db";
|
||||
|
||||
/** Le litige ne modifie ni le score, ni la validation BAP, ni l’export. */
|
||||
export const disputeInput = z.object({
|
||||
id: z.number().int().positive(),
|
||||
isDisputed: z.boolean(),
|
||||
comment: z.string().max(5000, "Le commentaire est limité à 5 000 caractères.").nullable().optional(),
|
||||
});
|
||||
|
||||
export const listDisputes = protectedProcedure.query(({ ctx }) =>
|
||||
getDisputedInvoices(ctx.user.role === "admin" ? undefined : ctx.user.id),
|
||||
);
|
||||
|
||||
export const setDispute = protectedProcedure.input(disputeInput).mutation(async ({ ctx, input }) => {
|
||||
if (String(ctx.user.role) === "readonly") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Votre profil permet uniquement la consultation." });
|
||||
}
|
||||
const invoice = await getInvoiceById(input.id);
|
||||
if (!invoice || (ctx.user.role !== "admin" && invoice.userId !== ctx.user.id)) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Facture introuvable." });
|
||||
}
|
||||
await updateInvoice(input.id, {
|
||||
isDisputed: input.isDisputed ? 1 : 0,
|
||||
// L’absence du champ conserve le commentaire ; null ou une chaîne vide l’efface.
|
||||
...(input.comment !== undefined ? { disputeComment: input.comment?.trim() || null } : {}),
|
||||
});
|
||||
return { success: true } as const;
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@sha
|
||||
import { normalizeImportLogPagination } from "@shared/importLogPagination";
|
||||
import { getImportLogRetentionCutoff, isImportLogRetentionMonths } from "@shared/importLogRetention";
|
||||
import { webPortalUrlSchema } from "./webPortalUrl";
|
||||
import { listDisputes, setDispute } from "./invoiceDisputes";
|
||||
|
||||
interface Condition {
|
||||
field: string;
|
||||
@@ -195,6 +196,8 @@ export const appRouter = router({
|
||||
|
||||
// ============= INVOICE ROUTES =============
|
||||
invoices: router({
|
||||
listDisputes,
|
||||
setDispute,
|
||||
// Upload and process PDF file
|
||||
upload: protectedProcedure
|
||||
.input(z.object({
|
||||
|
||||
Reference in New Issue
Block a user