feat(erp): facturation KissMetrics en dollars, part différée M3 retrouvée, et le format de lettre commerciale (#97)

Co-authored-by: Gabriel Radureau <[email protected]>
This commit was merged in pull request #97.
This commit is contained in:
2026-08-24 19:30:03 +02:00
committed by arcodange
parent 58705fda56
commit 09e98ea063
24 changed files with 23831 additions and 37 deletions
+16
View File
@@ -0,0 +1,16 @@
// Rend un emoji en PNG transparent via Chromium, qui sait lire Apple Color
// Emoji là où weasyprint échoue (format bitmap sbix, non géré).
import { chromium } from "playwright";
const [outDir, ...emojis] = Deno.args;
const b = await chromium.launch({ headless: true });
const p = await (await b.newContext({ deviceScaleFactor: 8 })).newPage();
for (const [i, e] of emojis.entries()) {
await p.setContent(
`<body style="margin:0"><span id="g" style="font-family:'Apple Color Emoji';` +
`font-size:64px;line-height:1;display:inline-block">${e}</span></body>`);
const el = p.locator("#g");
const f = `${outDir}/emoji-${i + 1}.png`;
await el.screenshot({ path: f, omitBackground: true });
console.log(` ${e} -> ${f}`);
}
await b.close();
+187
View File
@@ -0,0 +1,187 @@
/*
Passe une facture client dans une autre devise, sans toucher à sa contre-valeur
en euros.
POURQUOI CE CHEMIN EXISTE. `PUT /invoices/{id}` ACCEPTE les champs
`multicurrency_code` et `multicurrency_tx` et répond 200 — mais ne les
applique pas : `Facture::update()` ne les traite pas. La facture reste en EUR
et rien ne le signale. Seule l'interface expose les actions
`editmulticurrencycode` et `editmulticurrencyrate`.
POURQUOI LE SCRIPT REMET AUSSI LA LIGNE. Changer la devise ABÎME la ligne :
Dolibarr recalcule `montant_devise = euros × taux_du_dictionnaire`, puis le
changement de taux fige la devise et recalcule les euros. La contre-valeur
euro se met donc à dériver. Le seul moyen de la ramener est de réécrire la
ligne en donnant les DEUX prix — `subprice` en euros, `multicurrency_subprice`
en devise. Les deux opérations sont inséparables ; les séparer laisserait la
facture dans un état faux entre les deux.
Et `PUT /invoices/{id}/lines/{lid}` n'est pas un PATCH : les champs absents du
corps sont remis à zéro — `desc` effacé, `product_type` ramené de service à
produit. Le script relit donc la ligne et la renvoie ENTIÈRE.
CE QUI NE DOIT PAS BOUGER : la contre-valeur en euros. Les livres sont tenus
en euros (C. com. art. L.123-22) ; libeller la facture en devise ne doit pas y
déplacer un centime. Le script le vérifie et refuse si l'euro a dérivé.
RAPPEL DE DROIT. Une facture peut être libellée dans toute monnaie
(CGI art. 289, II — directive 2006/112, art. 230) ; seule la TVA à payer doit
être déterminée en euros. Sans TVA française — autoliquidation par un preneur
hors UE, CGI art. 259-1° et 283-2 — la contrainte ne s'applique pas.
Usage :
deno run -A test/setInvoiceCurrency.ts --id 15 --code USD \
--rate 1.164992 --mc-amount 3000.00 [--dry-run]
*/
import "load_dotenv";
import { chromium } from "playwright";
import login from "./scripts/login.ts";
import { assertSandbox } from "./scripts/guard.ts";
const argv = Deno.args;
const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d);
const id = pick("--id");
const code = pick("--code", "USD");
const rate = pick("--rate");
const mcAmount = pick("--mc-amount"); // prix unitaire dans la devise
const dryRun = argv.includes("--dry-run");
if (!id || !rate || !mcAmount) {
console.error("--id, --rate et --mc-amount sont requis");
Deno.exit(2);
}
const dolibarrAddress = assertSandbox();
const apiUrl = Deno.env.get("DOLIBARR_URL") || dolibarrAddress;
const apiKey = Deno.env.get("DOLIBARR_API_KEY") || "";
console.log(`cible : ${dolibarrAddress}`);
console.log(`facture : id=${id}${mcAmount} ${code} au taux ${rate}`);
/** Relit la facture par l'API : la page de retour ne prouve rien. */
async function etat(): Promise<Record<string, unknown>> {
const r = await fetch(`${apiUrl}/api/index.php/invoices/${id}`, {
headers: { DOLAPIKEY: apiKey, Accept: "application/json" },
});
return await r.json();
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
try {
const avant = await etat();
console.log(`avant : ${avant.multicurrency_code} taux ${avant.multicurrency_tx}` +
`HT ${avant.total_ht} EUR, ${avant.multicurrency_total_ht} en devise`);
if (avant.multicurrency_code === code && Number(avant.multicurrency_tx) === Number(rate)) {
console.log("déjà dans cette devise à ce taux, rien à faire.");
Deno.exit(0);
}
// La devise ne se change que sur un brouillon. On y passe nous-même plutôt
// que d'exiger de l'appelant une étape séparée : la remise en brouillon fait
// partie de l'opération, pas de sa préparation.
if (String(avant.status) !== "0") {
if (String(avant.paye) === "1") {
console.error("facture RÉGLÉE — refus. Changer la devise d'une facture encaissée " +
"déplacerait un montant déjà rapproché de la banque.");
Deno.exit(1);
}
console.log(`statut ${avant.status} → remise en brouillon`);
if (!dryRun) {
const r = await fetch(`${apiUrl}/api/index.php/invoices/${id}/settodraft`, {
method: "POST",
headers: { DOLAPIKEY: apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ idwarehouse: 0 }),
});
if (!r.ok) { console.error(`settodraft a échoué : HTTP ${r.status}`); Deno.exit(1); }
}
}
if (dryRun) {
console.log("\n--dry-run : rien n'est soumis.");
Deno.exit(0);
}
await login.doAdminLogin({
page,
dolibarrAddress,
adminCredentials: {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
},
});
/** Ouvre une action d'édition en ligne et soumet le formulaire qui la porte. */
async function editer(action: string, remplir: () => Promise<void>): Promise<void> {
await page.goto(`${dolibarrAddress}/compta/facture/card.php?facid=${id}`);
const href = await page.locator(`a[href*="${action}"]`).first().getAttribute("href");
if (!href) throw new Error(`action ${action} absente de la fiche — droits, ou module inactif`);
await page.goto(new URL(href, dolibarrAddress).toString());
await remplir();
await page.waitForLoadState("networkidle");
}
await editer("editmulticurrencycode", async () => {
// Le select n'a pas de nom stable d'une version à l'autre : on le trouve
// par l'option qu'il contient.
const sel = page.locator(`select:has(option[value="${code}"])`).first();
await sel.selectOption(code);
await sel.locator("xpath=ancestor::form").locator('input[type="submit"]').first().click();
});
await editer("editmulticurrencyrate", async () => {
const inp = page.locator('input[name="multicurrency_tx"], input[name="rate"]').first();
await inp.fill(rate);
await inp.locator("xpath=ancestor::form").locator('input[type="submit"]').first().click();
});
// Réparation de la ligne, indissociable de ce qui précède. On relit d'abord :
// le PUT n'est pas un PATCH, tout champ omis serait remis à zéro.
const ligne = ((await etat()).lines as Record<string, string>[])[0];
const eurAvant = String(avant.total_ht);
const puEur = (Number(eurAvant) / Number(ligne.qty || 1)).toFixed(2);
const rep = await fetch(`${apiUrl}/api/index.php/invoices/${id}/lines/${ligne.id}`, {
method: "PUT",
headers: { DOLAPIKEY: apiKey, "Content-Type": "application/json" },
body: JSON.stringify({
desc: ligne.desc, description: ligne.desc,
subprice: puEur, pu_ht: puEur, multicurrency_subprice: mcAmount,
qty: ligne.qty, product_type: "1", tva_tx: ligne.tva_tx,
remise_percent: ligne.remise_percent,
localtax1_tx: ligne.localtax1_tx, localtax2_tx: ligne.localtax2_tx,
info_bits: ligne.info_bits, special_code: ligne.special_code, rang: ligne.rang,
situation_percent: ligne.situation_percent ?? "100",
fk_warehouse: ligne.fk_warehouse ?? "0", pa_ht: ligne.pa_ht ?? "0",
}),
});
if (!rep.ok) { console.error(`réécriture de la ligne : HTTP ${rep.status}`); Deno.exit(1); }
const apres = await etat();
console.log(`après : ${apres.multicurrency_code} taux ${apres.multicurrency_tx}` +
`HT ${apres.total_ht} EUR, ${apres.multicurrency_total_ht} en devise`);
if (apres.multicurrency_code !== code) {
console.error("ÉCHEC — la devise relue n'est pas celle demandée.");
Deno.exit(1);
}
if (Number(apres.total_ht) !== Number(avant.total_ht)) {
console.error(`ÉCHEC — la contre-valeur euro a bougé : ${avant.total_ht} -> ${apres.total_ht}. ` +
"Le passage en devise ne doit RIEN déplacer dans les livres.");
Deno.exit(1);
}
if (Math.abs(Number(apres.multicurrency_total_ht) - Number(mcAmount)) > 0.01) {
console.error(`ÉCHEC — montant en devise ${apres.multicurrency_total_ht}, attendu ${mcAmount}.`);
Deno.exit(1);
}
const lf = (apres.lines as Record<string, string>[])[0];
if (!(lf.desc || "").trim() || lf.product_type !== "1") {
console.error(`ÉCHEC — la ligne a été abîmée : desc=${lf.desc ? "ok" : "VIDE"}, ` +
`product_type=${lf.product_type} (attendu 1).`);
Deno.exit(1);
}
console.log("ok — devise posée, contre-valeur euro inchangée, ligne intacte, relu par l'API.");
} finally {
await context.close();
await browser.close();
}