Checkpoint: Correctifs validés en sandbox : retour contextuel depuis le détail vers Factures BAP avec filtres et tri conservés, et nouvelle action Abonnement Oui/Non/Ne pas modifier dans les automatismes. Ajout de tests de non-régression.
This commit is contained in:
83
client/src/lib/bapNavigation.ts
Normal file
83
client/src/lib/bapNavigation.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Paramètres d’affichage qui doivent survivre au passage par le détail d’une
|
||||||
|
* facture BAP. Seules ces clés sont sérialisées afin d’éviter de propager des
|
||||||
|
* paramètres de navigation arbitraires.
|
||||||
|
*/
|
||||||
|
export type BapFilters = {
|
||||||
|
searchQuery: string;
|
||||||
|
statusFilter: string;
|
||||||
|
selectedYear: string;
|
||||||
|
selectedMonth: string;
|
||||||
|
sortField: "invoiceDate" | "createdAt";
|
||||||
|
sortDir: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
|
const BAP_STATUSES = new Set([
|
||||||
|
"all",
|
||||||
|
"exported",
|
||||||
|
"not_exported",
|
||||||
|
"export_error",
|
||||||
|
"bap_validated",
|
||||||
|
"bap_pending",
|
||||||
|
"to_complete",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function asSearchParams(search: string): URLSearchParams {
|
||||||
|
return new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMonth(value: string | null): value is string {
|
||||||
|
return value !== null && /^(0[1-9]|1[0-2])$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYear(value: string | null): value is string {
|
||||||
|
return value === "all" || (value !== null && /^\d{4}$/.test(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lit et valide les filtres BAP transmis dans l’URL. */
|
||||||
|
export function parseBapFilters(search: string, defaultYear: string): BapFilters {
|
||||||
|
const params = asSearchParams(search);
|
||||||
|
const status = params.get("status");
|
||||||
|
const year = params.get("year");
|
||||||
|
const month = params.get("month");
|
||||||
|
const sort = params.get("sort");
|
||||||
|
const direction = params.get("dir");
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery: params.get("q") || "",
|
||||||
|
statusFilter: status && BAP_STATUSES.has(status) ? status : "all",
|
||||||
|
selectedYear: isYear(year) ? year : defaultYear,
|
||||||
|
selectedMonth: month === "all" || isMonth(month) ? month : "all",
|
||||||
|
sortField: sort === "invoiceDate" ? "invoiceDate" : "createdAt",
|
||||||
|
sortDir: direction === "asc" ? "asc" : "desc",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Construit une URL de détail qui conserve explicitement le contexte BAP. */
|
||||||
|
export function buildBapDetailLocation(invoiceId: number, filters: BapFilters): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
returnTo: "bap",
|
||||||
|
q: filters.searchQuery,
|
||||||
|
status: filters.statusFilter,
|
||||||
|
year: filters.selectedYear,
|
||||||
|
month: filters.selectedMonth,
|
||||||
|
sort: filters.sortField,
|
||||||
|
dir: filters.sortDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `/invoices/${invoiceId}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne la liste d’origine. Sans contexte BAP, le comportement historique est conservé. */
|
||||||
|
export function getBapReturnLocation(search: string): string {
|
||||||
|
const params = asSearchParams(search);
|
||||||
|
if (params.get("returnTo") !== "bap") return "/invoices";
|
||||||
|
|
||||||
|
const allowed = new URLSearchParams();
|
||||||
|
for (const key of ["q", "status", "year", "month", "sort", "dir"]) {
|
||||||
|
const value = params.get(key);
|
||||||
|
if (value) allowed.set(key, value);
|
||||||
|
}
|
||||||
|
const query = allowed.toString();
|
||||||
|
return query ? `/invoices-bap?${query}` : "/invoices-bap";
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ interface Actions {
|
|||||||
typeAchat?: string;
|
typeAchat?: string;
|
||||||
serviceConcerne?: string;
|
serviceConcerne?: string;
|
||||||
ventilationComptable?: string;
|
ventilationComptable?: string;
|
||||||
|
isSubscription?: 0 | 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationRules() {
|
export default function AutomationRules() {
|
||||||
@@ -84,6 +85,12 @@ export default function AutomationRules() {
|
|||||||
return val;
|
return val;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeSubscriptionAction = (value: unknown): 0 | 1 | undefined => {
|
||||||
|
if (value === 1 || value === "1" || value === "OUI") return 1;
|
||||||
|
if (value === 0 || value === "0" || value === "NON") return 0;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const createMutation = trpc.automationRules.create.useMutation({
|
const createMutation = trpc.automationRules.create.useMutation({
|
||||||
onSuccess: (rule) => {
|
onSuccess: (rule) => {
|
||||||
if (rule.isActive === 1) {
|
if (rule.isActive === 1) {
|
||||||
@@ -204,7 +211,7 @@ export default function AutomationRules() {
|
|||||||
} else {
|
} else {
|
||||||
setCustomVentilation("");
|
setCustomVentilation("");
|
||||||
}
|
}
|
||||||
setActions(parsedActions);
|
setActions({ ...parsedActions, isSubscription: normalizeSubscriptionAction(parsedActions.isSubscription) });
|
||||||
} catch {
|
} catch {
|
||||||
setActions({});
|
setActions({});
|
||||||
setCustomTypeAchat("");
|
setCustomTypeAchat("");
|
||||||
@@ -231,11 +238,13 @@ export default function AutomationRules() {
|
|||||||
typeAchat: resolveAction(actions.typeAchat, customTypeAchat),
|
typeAchat: resolveAction(actions.typeAchat, customTypeAchat),
|
||||||
serviceConcerne: resolveAction(actions.serviceConcerne, customServiceConcerne),
|
serviceConcerne: resolveAction(actions.serviceConcerne, customServiceConcerne),
|
||||||
ventilationComptable: resolveAction(actions.ventilationComptable, customVentilation),
|
ventilationComptable: resolveAction(actions.ventilationComptable, customVentilation),
|
||||||
|
isSubscription: actions.isSubscription,
|
||||||
};
|
};
|
||||||
// Supprimer les clés undefined
|
// Supprimer les clés undefined
|
||||||
if (!resolvedActions.typeAchat) delete resolvedActions.typeAchat;
|
if (!resolvedActions.typeAchat) delete resolvedActions.typeAchat;
|
||||||
if (!resolvedActions.serviceConcerne) delete resolvedActions.serviceConcerne;
|
if (!resolvedActions.serviceConcerne) delete resolvedActions.serviceConcerne;
|
||||||
if (!resolvedActions.ventilationComptable) delete resolvedActions.ventilationComptable;
|
if (!resolvedActions.ventilationComptable) delete resolvedActions.ventilationComptable;
|
||||||
|
if (resolvedActions.isSubscription === undefined) delete resolvedActions.isSubscription;
|
||||||
const actionsJSON = JSON.stringify(resolvedActions);
|
const actionsJSON = JSON.stringify(resolvedActions);
|
||||||
|
|
||||||
if (editingRule) {
|
if (editingRule) {
|
||||||
@@ -372,6 +381,7 @@ export default function AutomationRules() {
|
|||||||
if (acts.typeAchat) actionsArr.push(`Type: ${acts.typeAchat}`);
|
if (acts.typeAchat) actionsArr.push(`Type: ${acts.typeAchat}`);
|
||||||
if (acts.serviceConcerne) actionsArr.push(`Service: ${acts.serviceConcerne}`);
|
if (acts.serviceConcerne) actionsArr.push(`Service: ${acts.serviceConcerne}`);
|
||||||
if (acts.ventilationComptable) actionsArr.push(`Ventilation: ${acts.ventilationComptable}`);
|
if (acts.ventilationComptable) actionsArr.push(`Ventilation: ${acts.ventilationComptable}`);
|
||||||
|
if (acts.isSubscription !== undefined) actionsArr.push(`Abonnement: ${normalizeSubscriptionAction(acts.isSubscription) === 1 ? "Oui" : "Non"}`);
|
||||||
actionsDisplay = actionsArr.join(", ");
|
actionsDisplay = actionsArr.join(", ");
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
@@ -610,6 +620,23 @@ export default function AutomationRules() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="isSubscription">Abonnement</Label>
|
||||||
|
<Select
|
||||||
|
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
|
||||||
|
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Ne pas modifier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
|
||||||
|
<SelectItem value="1">Oui</SelectItem>
|
||||||
|
<SelectItem value="0">Non</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -642,7 +669,8 @@ export default function AutomationRules() {
|
|||||||
{actions.typeAchat && <div>• Type d'achat : {actions.typeAchat}</div>}
|
{actions.typeAchat && <div>• Type d'achat : {actions.typeAchat}</div>}
|
||||||
{actions.serviceConcerne && <div>• Service : {actions.serviceConcerne}</div>}
|
{actions.serviceConcerne && <div>• Service : {actions.serviceConcerne}</div>}
|
||||||
{actions.ventilationComptable && <div>• Ventilation : {actions.ventilationComptable}</div>}
|
{actions.ventilationComptable && <div>• Ventilation : {actions.ventilationComptable}</div>}
|
||||||
{!actions.typeAchat && !actions.serviceConcerne && !actions.ventilationComptable && (
|
{actions.isSubscription !== undefined && <div>• Abonnement : {actions.isSubscription === 1 ? "Oui" : "Non"}</div>}
|
||||||
|
{!actions.typeAchat && !actions.serviceConcerne && !actions.ventilationComptable && actions.isSubscription === undefined && (
|
||||||
<div className="text-gray-500 italic">Aucune action définie</div>
|
<div className="text-gray-500 italic">Aucune action définie</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -829,6 +857,23 @@ export default function AutomationRules() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="isSubscription">Abonnement</Label>
|
||||||
|
<Select
|
||||||
|
value={actions.isSubscription === undefined ? "__NONE__" : String(actions.isSubscription)}
|
||||||
|
onValueChange={(value) => setActions({ ...actions, isSubscription: value === "__NONE__" ? undefined : Number(value) as 0 | 1 })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Ne pas modifier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__NONE__">Ne pas modifier</SelectItem>
|
||||||
|
<SelectItem value="1">Oui</SelectItem>
|
||||||
|
<SelectItem value="0">Non</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useLocation } from "wouter";
|
import { useParams, useLocation, useSearch } from "wouter";
|
||||||
import DashboardLayout from "@/components/DashboardLayout";
|
import DashboardLayout from "@/components/DashboardLayout";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -9,10 +9,13 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { ArrowLeft, Save, FileText } from "lucide-react";
|
import { ArrowLeft, Save, FileText } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getBapReturnLocation } from "@/lib/bapNavigation";
|
||||||
|
|
||||||
export default function InvoiceDetail() {
|
export default function InvoiceDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
|
const detailSearch = useSearch();
|
||||||
|
const returnLocation = getBapReturnLocation(detailSearch);
|
||||||
const invoiceId = parseInt(id || "0");
|
const invoiceId = parseInt(id || "0");
|
||||||
|
|
||||||
const { data: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId });
|
const { data: invoice, isLoading } = trpc.invoices.getById.useQuery({ id: invoiceId });
|
||||||
@@ -172,7 +175,7 @@ export default function InvoiceDetail() {
|
|||||||
<FileText className="w-16 h-16 text-gray-300 mb-4" />
|
<FileText className="w-16 h-16 text-gray-300 mb-4" />
|
||||||
<h2 className="text-2xl font-bold mb-2">Facture introuvable</h2>
|
<h2 className="text-2xl font-bold mb-2">Facture introuvable</h2>
|
||||||
<p className="text-gray-500 mb-4">Cette facture n'existe pas ou a été supprimée</p>
|
<p className="text-gray-500 mb-4">Cette facture n'existe pas ou a été supprimée</p>
|
||||||
<Button onClick={() => setLocation("/invoices")}>
|
<Button onClick={() => setLocation(returnLocation)}>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Retour aux factures
|
Retour aux factures
|
||||||
</Button>
|
</Button>
|
||||||
@@ -187,7 +190,7 @@ export default function InvoiceDetail() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" onClick={() => setLocation("/invoices")}>
|
<Button variant="ghost" onClick={() => setLocation(returnLocation)}>
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Retour
|
Retour
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ function downloadBapPdf(
|
|||||||
}
|
}
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation, useSearch } from "wouter";
|
||||||
|
import { buildBapDetailLocation, parseBapFilters } from "@/lib/bapNavigation";
|
||||||
|
|
||||||
// Helper function to determine field color based on origin
|
// Helper function to determine field color based on origin
|
||||||
const getFieldColor = (invoice: any, fieldName: string): string => {
|
const getFieldColor = (invoice: any, fieldName: string): string => {
|
||||||
@@ -108,9 +109,12 @@ const getFieldColor = (invoice: any, fieldName: string): string => {
|
|||||||
|
|
||||||
export default function InvoicesBAP() {
|
export default function InvoicesBAP() {
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const queryString = useSearch();
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const initialFilters = parseBapFilters(queryString, String(currentYear));
|
||||||
|
const [searchQuery, setSearchQuery] = useState(initialFilters.searchQuery);
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
const [statusFilter, setStatusFilter] = useState<string>(initialFilters.statusFilter);
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
|
const [invoiceToDelete, setInvoiceToDelete] = useState<number | null>(null);
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
@@ -126,12 +130,22 @@ export default function InvoicesBAP() {
|
|||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
const [allExpanded, setAllExpanded] = useState(false);
|
const [allExpanded, setAllExpanded] = useState(false);
|
||||||
// Filtre par période
|
// Filtre par période
|
||||||
const currentYear = new Date().getFullYear();
|
const [selectedYear, setSelectedYear] = useState<string>(initialFilters.selectedYear);
|
||||||
const [selectedYear, setSelectedYear] = useState<string>(String(currentYear));
|
const [selectedMonth, setSelectedMonth] = useState<string>(initialFilters.selectedMonth); // "all" ou "01".."12"
|
||||||
const [selectedMonth, setSelectedMonth] = useState<string>("all"); // "all" ou "01".."12"
|
|
||||||
// Tri
|
// Tri
|
||||||
const [sortField, setSortField] = useState<"invoiceDate" | "createdAt">("createdAt");
|
const [sortField, setSortField] = useState<"invoiceDate" | "createdAt">(initialFilters.sortField);
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">(initialFilters.sortDir);
|
||||||
|
|
||||||
|
const openInvoiceDetail = (invoiceId: number) => {
|
||||||
|
setLocation(buildBapDetailLocation(invoiceId, {
|
||||||
|
searchQuery,
|
||||||
|
statusFilter,
|
||||||
|
selectedYear,
|
||||||
|
selectedMonth,
|
||||||
|
sortField,
|
||||||
|
sortDir,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const toggleRow = (id: number) => {
|
const toggleRow = (id: number) => {
|
||||||
setExpandedIds(prev => {
|
setExpandedIds(prev => {
|
||||||
@@ -926,7 +940,7 @@ export default function InvoicesBAP() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => setLocation(`/invoices/${invoice.id}`)}
|
onClick={() => openInvoiceDetail(invoice.id)}
|
||||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
>
|
>
|
||||||
{invoice.supplierName || "Inconnu"}
|
{invoice.supplierName || "Inconnu"}
|
||||||
@@ -976,7 +990,7 @@ export default function InvoicesBAP() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setLocation(`/invoices/${invoice.id}`)}
|
onClick={() => openInvoiceDetail(invoice.id)}
|
||||||
className="h-8 px-2"
|
className="h-8 px-2"
|
||||||
title="Modifier"
|
title="Modifier"
|
||||||
>
|
>
|
||||||
|
|||||||
66
server/automationEngine.test.ts
Normal file
66
server/automationEngine.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Invoice } from "../drizzle/schema";
|
||||||
|
import { getAutomationRulesByUser } from "./db";
|
||||||
|
import { applyAutomationRules } from "./automationEngine";
|
||||||
|
|
||||||
|
vi.mock("./db", () => ({
|
||||||
|
getAutomationRulesByUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedGetAutomationRules = vi.mocked(getAutomationRulesByUser);
|
||||||
|
|
||||||
|
const invoice = {
|
||||||
|
id: 1,
|
||||||
|
supplierName: "Microsoft Ireland Operations Ltd",
|
||||||
|
isSubscription: 0,
|
||||||
|
} as Invoice;
|
||||||
|
|
||||||
|
describe("applyAutomationRules", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedGetAutomationRules.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applique Abonnement = Oui et trace le champ automatisé", async () => {
|
||||||
|
mockedGetAutomationRules.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
name: "Microsoft est un abonnement",
|
||||||
|
isActive: 1,
|
||||||
|
priority: 1,
|
||||||
|
conditions: JSON.stringify([{ field: "supplierName", operator: "contains", value: "microsoft" }]),
|
||||||
|
conditionsLogic: "AND",
|
||||||
|
actions: JSON.stringify({ isSubscription: 1 }),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
await expect(applyAutomationRules(1, invoice)).resolves.toMatchObject({
|
||||||
|
isSubscription: 1,
|
||||||
|
autoFilledFields: JSON.stringify(["isSubscription"]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autorise Abonnement = Non sans le confondre avec une absence d’action", async () => {
|
||||||
|
mockedGetAutomationRules.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
userId: 1,
|
||||||
|
name: "Microsoft n’est pas un abonnement",
|
||||||
|
isActive: 1,
|
||||||
|
priority: 1,
|
||||||
|
conditions: JSON.stringify([{ field: "supplierName", operator: "contains", value: "microsoft" }]),
|
||||||
|
conditionsLogic: "AND",
|
||||||
|
actions: JSON.stringify({ isSubscription: 0 }),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
await expect(applyAutomationRules(1, invoice)).resolves.toMatchObject({
|
||||||
|
isSubscription: 0,
|
||||||
|
autoFilledFields: JSON.stringify(["isSubscription"]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ interface Actions {
|
|||||||
typeAchat?: string;
|
typeAchat?: string;
|
||||||
serviceConcerne?: string;
|
serviceConcerne?: string;
|
||||||
ventilationComptable?: string;
|
ventilationComptable?: string;
|
||||||
|
isSubscription?: 0 | 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +112,11 @@ export async function applyAutomationRules(
|
|||||||
updates.ventilationComptable = actions.ventilationComptable;
|
updates.ventilationComptable = actions.ventilationComptable;
|
||||||
autoFilledFieldsList.push("ventilationComptable");
|
autoFilledFieldsList.push("ventilationComptable");
|
||||||
}
|
}
|
||||||
|
// 0 est une valeur métier valide : ne jamais la tester par vérité.
|
||||||
|
if (actions.isSubscription !== undefined && updates.isSubscription === undefined) {
|
||||||
|
updates.isSubscription = actions.isSubscription;
|
||||||
|
autoFilledFieldsList.push("isSubscription");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[AutomationEngine] Error processing rule ${rule.id}:`, error);
|
console.error(`[AutomationEngine] Error processing rule ${rule.id}:`, error);
|
||||||
|
|||||||
52
server/bapNavigation.test.ts
Normal file
52
server/bapNavigation.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildBapDetailLocation,
|
||||||
|
getBapReturnLocation,
|
||||||
|
parseBapFilters,
|
||||||
|
} from "../client/src/lib/bapNavigation";
|
||||||
|
|
||||||
|
describe("navigation Factures BAP", () => {
|
||||||
|
it("transmet tous les filtres et le tri à la page de détail", () => {
|
||||||
|
const location = buildBapDetailLocation(42, {
|
||||||
|
searchQuery: "microsoft europe",
|
||||||
|
statusFilter: "bap_pending",
|
||||||
|
selectedYear: "2026",
|
||||||
|
selectedMonth: "05",
|
||||||
|
sortField: "invoiceDate",
|
||||||
|
sortDir: "asc",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(location).toBe(
|
||||||
|
"/invoices/42?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&sort=invoiceDate&dir=asc"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restaure la liste BAP et les filtres de façon sûre", () => {
|
||||||
|
const detailSearch =
|
||||||
|
"?returnTo=bap&q=microsoft+europe&status=bap_pending&year=2026&month=05&sort=invoiceDate&dir=asc";
|
||||||
|
|
||||||
|
expect(getBapReturnLocation(detailSearch)).toBe(
|
||||||
|
"/invoices-bap?q=microsoft+europe&status=bap_pending&year=2026&month=05&sort=invoiceDate&dir=asc"
|
||||||
|
);
|
||||||
|
expect(parseBapFilters(detailSearch, "2026")).toEqual({
|
||||||
|
searchQuery: "microsoft europe",
|
||||||
|
statusFilter: "bap_pending",
|
||||||
|
selectedYear: "2026",
|
||||||
|
selectedMonth: "05",
|
||||||
|
sortField: "invoiceDate",
|
||||||
|
sortDir: "asc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retombe sur les valeurs BAP sûres si les paramètres sont absents ou invalides", () => {
|
||||||
|
expect(parseBapFilters("?returnTo=bap&status=invalid&year=x&sort=nope&dir=down", "2026")).toEqual({
|
||||||
|
searchQuery: "",
|
||||||
|
statusFilter: "all",
|
||||||
|
selectedYear: "2026",
|
||||||
|
selectedMonth: "all",
|
||||||
|
sortField: "createdAt",
|
||||||
|
sortDir: "desc",
|
||||||
|
});
|
||||||
|
expect(getBapReturnLocation("?returnTo=invoices")).toBe("/invoices");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ interface Actions {
|
|||||||
typeAchat?: string;
|
typeAchat?: string;
|
||||||
serviceConcerne?: string;
|
serviceConcerne?: string;
|
||||||
ventilationComptable?: string;
|
ventilationComptable?: string;
|
||||||
|
isSubscription?: 0 | 1;
|
||||||
}
|
}
|
||||||
import { getSessionCookieOptions } from "./_core/cookies";
|
import { getSessionCookieOptions } from "./_core/cookies";
|
||||||
import { systemRouter } from "./_core/systemRouter";
|
import { systemRouter } from "./_core/systemRouter";
|
||||||
@@ -2028,7 +2029,7 @@ export const appRouter = router({
|
|||||||
|
|
||||||
for (const invoice of bapInvoices) {
|
for (const invoice of bapInvoices) {
|
||||||
const updates = await applyAutomationRules(ctx.user.id, invoice);
|
const updates = await applyAutomationRules(ctx.user.id, invoice);
|
||||||
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) {
|
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.isSubscription !== undefined || updates.autoFilledFields) {
|
||||||
await updateInvoice(invoice.id, updates);
|
await updateInvoice(invoice.id, updates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2059,7 +2060,7 @@ export const appRouter = router({
|
|||||||
|
|
||||||
for (const invoice of bapInvoices) {
|
for (const invoice of bapInvoices) {
|
||||||
const updates = await applyAutomationRules(ctx.user.id, invoice);
|
const updates = await applyAutomationRules(ctx.user.id, invoice);
|
||||||
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.autoFilledFields) {
|
if (updates.typeAchat || updates.serviceConcerne || updates.ventilationComptable || updates.isSubscription !== undefined || updates.autoFilledFields) {
|
||||||
await updateInvoice(invoice.id, updates);
|
await updateInvoice(invoice.id, updates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
todo.md
20
todo.md
@@ -740,3 +740,23 @@
|
|||||||
- [x] Corriger les règles Docker orphelines qui bloquaient MySQL en recette
|
- [x] Corriger les règles Docker orphelines qui bloquaient MySQL en recette
|
||||||
- [x] Pousser la version validée vers le dépôt Gitea de production
|
- [x] Pousser la version validée vers le dépôt Gitea de production
|
||||||
- [x] Déployer et valider HTTP, conteneurs et OAuth2 ImapFlow en production
|
- [x] Déployer et valider HTTP, conteneurs et OAuth2 ImapFlow en production
|
||||||
|
|
||||||
|
## Navigation depuis l’édition des factures BAP
|
||||||
|
- [x] Reproduire et identifier la perte du contexte de navigation depuis Factures BAP
|
||||||
|
- [x] Restaurer la page d’origine après fermeture de l’édition
|
||||||
|
- [x] Conserver les filtres et tris sélectionnés sur Factures BAP
|
||||||
|
- [x] Ajouter des tests de non-régression du retour contextuel
|
||||||
|
- [x] Vérifier TypeScript, tests, build et parcours en sandbox
|
||||||
|
- [ ] Pousser le correctif vers Gitea recette et redéployer
|
||||||
|
- [ ] Vérifier le parcours et la santé de la recette
|
||||||
|
|
||||||
|
## Automatisation de l’indicateur Abonnement
|
||||||
|
- [x] Analyser le modèle, le formulaire et l’exécution actuelle des automatismes
|
||||||
|
- [x] Ajouter l’option Abonnement : Oui, Non ou Ne pas modifier aux règles
|
||||||
|
- [x] Appliquer l’option Abonnement lors du traitement automatique des factures
|
||||||
|
- [x] Ajouter des tests de non-régression des règles d’abonnement
|
||||||
|
- [x] Valider les deux correctifs en sandbox
|
||||||
|
|
||||||
|
## Déploiement recette — navigation BAP et abonnement
|
||||||
|
- [ ] Pousser les deux correctifs vers Gitea recette
|
||||||
|
- [ ] Déployer, vérifier HTTP et valider le parcours de retour en recette
|
||||||
|
|||||||
Reference in New Issue
Block a user