feat: ajouter les factures litiges et leurs commentaires (recette)
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m14s

This commit is contained in:
Manus
2026-11-03 14:45:05 +00:00
parent 5f6c61d2f0
commit a6d90a90c3
16 changed files with 3320 additions and 13 deletions

View File

@@ -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");

View File

@@ -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");
});

View 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"
);
});
});

View 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
View 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;
});

View File

@@ -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({