Compare commits

...

4 Commits

10 changed files with 381 additions and 20 deletions

View File

@@ -0,0 +1,83 @@
/**
* Paramètres daffichage qui doivent survivre au passage par le détail dune
* 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 lURL. */
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 dorigine. 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";
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
import { ImapFlow } from "imapflow";
import mysql from "mysql2/promise";
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("Variable DATABASE_URL manquante");
const connection = await mysql.createConnection(databaseUrl);
try {
const [rows] = await connection.query(`
SELECT emailImportAddress, emailImportHost, emailImportPort,
azureTenantId, azureClientId, azureClientSecret
FROM importSettings
WHERE emailImportEnabled = 1
AND emailImportAuthMode = 'oauth2'
ORDER BY id
LIMIT 1
`);
const settings = rows[0];
if (!settings) throw new Error("Aucune configuration OAuth2 IMAP active");
const tenantId = settings.azureTenantId || process.env.AZURE_AD_TENANT_ID;
const clientId = settings.azureClientId || process.env.AZURE_AD_CLIENT_ID;
const clientSecret = settings.azureClientSecret || process.env.AZURE_AD_CLIENT_SECRET;
if (!tenantId || !clientId || !clientSecret) {
throw new Error("Configuration Azure AD incomplète");
}
const response = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
scope: "https://outlook.office365.com/.default",
grant_type: "client_credentials",
}),
},
);
const tokenResponse = await response.json();
if (!response.ok || !tokenResponse.access_token) {
throw new Error(`Échec OAuth2 : ${tokenResponse.error || response.status}`);
}
const host = settings.emailImportHost || "outlook.office365.com";
const client = new ImapFlow({
host,
port: settings.emailImportPort || 993,
secure: true,
auth: {
user: settings.emailImportAddress,
accessToken: tokenResponse.access_token,
},
tls: { servername: host, rejectUnauthorized: true },
verifyOnly: true,
logger: false,
});
try {
await client.connect();
console.log(`Authentification ImapFlow OAuth2 réussie pour ${settings.emailImportAddress}`);
} finally {
if (client.usable) await client.logout().catch(() => client.close());
else client.close();
}
} finally {
await connection.end();
}

View 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 daction", async () => {
mockedGetAutomationRules.mockResolvedValue([
{
id: 2,
userId: 1,
name: "Microsoft nest 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"]),
});
});
});

View File

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

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

View File

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

27
todo.md
View File

@@ -736,6 +736,27 @@
- [x] Pousser la version ImapFlow vers le dépôt Gitea de recette - [x] Pousser la version ImapFlow vers le dépôt Gitea de recette
- [x] Réduire limage runtime Docker pour fiabiliser le build sur le serveur de recette - [x] Réduire limage runtime Docker pour fiabiliser le build sur le serveur de recette
- [x] Charger Vite uniquement en développement pour lexclure de limage runtime - [x] Charger Vite uniquement en développement pour lexclure de limage runtime
- [ ] Déployer et valider HTTP, conteneurs et OAuth2 ImapFlow en recette - [x] Déployer et valider HTTP et conteneurs ImapFlow en recette (aucune source OAuth2 active à tester)
- [ ] Pousser la version validée vers le dépôt Gitea de production - [x] Corriger les règles Docker orphelines qui bloquaient MySQL en recette
- [ ] Déployer et valider HTTP, conteneurs et OAuth2 ImapFlow en 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
## Navigation depuis lédition des factures BAP
- [x] Reproduire et identifier la perte du contexte de navigation depuis Factures BAP
- [x] Restaurer la page dorigine 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 lindicateur Abonnement
- [x] Analyser le modèle, le formulaire et lexécution actuelle des automatismes
- [x] Ajouter loption Abonnement : Oui, Non ou Ne pas modifier aux règles
- [x] Appliquer loption Abonnement lors du traitement automatique des factures
- [x] Ajouter des tests de non-régression des règles dabonnement
- [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