Files
erp/test/setInvoiceCurrency.ts
T

188 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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();
}