feat(erp): indemnité d'occupation janv→juil 2026 (1 483,23 €) — paiements divers, sans tiers (#89)
Co-authored-by: Gabriel Radureau <[email protected]>
This commit was merged in pull request #89.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
Enregistre un PAIEMENT DIVERS Dolibarr — un mouvement sur un compte bancaire
|
||||
avec son code comptable, SANS tiers.
|
||||
|
||||
POURQUOI PAS UNE FACTURE FOURNISSEUR. Une dette envers l'associé n'est pas une
|
||||
dette fournisseur. Lui créer une fiche fournisseur le ferait apparaître au
|
||||
grand livre auxiliaire, dans les balances âgées et les états de dettes
|
||||
fournisseurs — exactement l'objection déjà opposée à l'URSSAF dans
|
||||
RUNBOOK_charges_sociales.md. Le gérant n'est pas un fournisseur de sa société.
|
||||
|
||||
Le modèle correct est direct : le compte bancaire `CCA1` (id 3) porte le numéro
|
||||
comptable 45511 et son propre journal. Un paiement divers en sens DÉBIT sur ce
|
||||
compte, code comptable 613, produit
|
||||
|
||||
débit 613xxx Locations (la charge)
|
||||
crédit 45511 G. RADUREAU, compte courant (la dette envers l'associé)
|
||||
|
||||
Aucun tiers, aucune facture, aucune pollution du grand livre auxiliaire.
|
||||
|
||||
PAS D'API REST : /variouspayments répond « API not found ». Comme pour les
|
||||
charges sociales, le pipeline `fleet/harness/promote/` — qui parle REST — ne
|
||||
peut pas porter cette opération. Ce script en garde la discipline (répétition
|
||||
sandbox, relecture, opt-in production explicite) mais pas le juge indépendant
|
||||
ni l'artefact de gate.
|
||||
|
||||
Usage :
|
||||
deno run -A test/recordVariousPayment.ts \
|
||||
--label "Indemnité d'occupation — janvier 2026" \
|
||||
--amount 163.23 --date 2026-01-31 --account 3 --code 613 [--dry-run]
|
||||
*/
|
||||
import "load_dotenv";
|
||||
import { chromium, type Page } 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 label = pick("--label");
|
||||
const amount = pick("--amount");
|
||||
const date = pick("--date"); // yyyy-mm-dd
|
||||
const account = pick("--account", "3"); // compte bancaire Dolibarr
|
||||
const code = pick("--code", "613"); // code comptable de la CHARGE
|
||||
const sens = pick("--sens", "0"); // 0 = Débit (sortie), 1 = Crédit
|
||||
const paymentType = pick("--payment-type", "VIR");
|
||||
const dryRun = argv.includes("--dry-run");
|
||||
|
||||
if (!label || !amount || !date) {
|
||||
console.error("--label, --amount et --date (yyyy-mm-dd) sont requis");
|
||||
Deno.exit(2);
|
||||
}
|
||||
|
||||
const dolibarrAddress = assertSandbox();
|
||||
const fr = (iso: string) => {
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}/${m}/${y}`;
|
||||
};
|
||||
|
||||
console.log(`cible : ${dolibarrAddress}`);
|
||||
console.log(`écriture: ${label} — ${amount} € — ${fr(date)} — compte ${account} — code ${code} — sens ${sens === "0" ? "débit" : "crédit"}`);
|
||||
if (dryRun) {
|
||||
console.log("\n--dry-run : rien n'est soumis.");
|
||||
Deno.exit(0);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ locale: "fr-FR" });
|
||||
const page = await context.newPage();
|
||||
|
||||
/**
|
||||
* Remplit une date Dolibarr : le champ visible pour l'humain, et le triplet
|
||||
* day/month/year CACHÉ que le backend est seul à lire. Remplir le seul champ
|
||||
* texte soumet une date vide — piège déjà payé sur les charges sociales.
|
||||
*/
|
||||
async function setDolibarrDate(name: string, iso: string): Promise<void> {
|
||||
const [y, m, d] = iso.split("-");
|
||||
await page.fill(`input[name="${name}"]`, fr(iso)).catch(() => {});
|
||||
await page.evaluate(
|
||||
({ n, dd, mm, yy }: { n: string; dd: string; mm: string; yy: string }) => {
|
||||
const doc = (globalThis as unknown as {
|
||||
document: { querySelector(s: string): { value: string } | null };
|
||||
}).document;
|
||||
const set = (suffix: string, v: string) => {
|
||||
const el = doc.querySelector(`input[name="${n}${suffix}"]`);
|
||||
if (el) el.value = v;
|
||||
};
|
||||
set("day", String(Number(dd)));
|
||||
set("month", String(Number(mm)));
|
||||
set("year", yy);
|
||||
},
|
||||
{ n: name, dd: d, mm: m, yy: y },
|
||||
);
|
||||
}
|
||||
|
||||
/** Cherche l'écriture dans la LISTE — seule confirmation digne de foi. */
|
||||
async function find(p: Page, lbl: string, amt: string): Promise<string | null> {
|
||||
await p.goto(`${dolibarrAddress}/compta/bank/various_payment/list.php?limit=200`);
|
||||
const rows = await p.locator("table.liste tr").evaluateAll(
|
||||
(trs: { textContent: string | null }[]) =>
|
||||
trs.map((t) => (t.textContent ?? "").replace(/\s+/g, " ").trim()),
|
||||
);
|
||||
// Les milliers s'affichent avec une espace insécable : comparer sans espaces,
|
||||
// sinon l'idempotence saute silencieusement au-delà de 999 €.
|
||||
const strip = (x: string) => x.replace(/[\s ]/g, "");
|
||||
const money = Number(amt).toFixed(2).replace(".", ",");
|
||||
// COMPARER LE LIBELLÉ ENTIER, jamais un préfixe. Une première version tronquait
|
||||
// à 24 caractères ; or « Indemnité d'occupation — » en fait exactement 24, si
|
||||
// bien que mars reconnaissait février et se déclarait déjà enregistré. Six mois
|
||||
// ont été silencieusement sautés. Un préfixe ne distingue que ce qui diffère
|
||||
// avant lui — hypothèse qu'aucun jeu de libellés ne garantit.
|
||||
const want = strip(lbl);
|
||||
return rows.find((r) => {
|
||||
const row = strip(r);
|
||||
if (!row.includes(money)) return false;
|
||||
if (row.includes(want)) return true;
|
||||
// Dolibarr abrège les libellés longs avec « … » : on accepte alors la
|
||||
// portion réellement affichée, et seulement elle.
|
||||
const cut = row.match(/(.{12,}?)…/);
|
||||
return !!cut && want.includes(cut[1].slice(-40));
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
await login.doAdminLogin({
|
||||
page,
|
||||
dolibarrAddress,
|
||||
adminCredentials: {
|
||||
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
|
||||
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
|
||||
},
|
||||
});
|
||||
|
||||
const already = await find(page, label, amount);
|
||||
if (already) {
|
||||
console.log(`déjà présente, rien à faire : ${already.slice(0, 110)}`);
|
||||
Deno.exit(0);
|
||||
}
|
||||
|
||||
await page.goto(`${dolibarrAddress}/compta/bank/various_payment/card.php?action=create`);
|
||||
await page.fill('input[name="label"]', label);
|
||||
await page.fill('input[name="amount"]', amount);
|
||||
await setDolibarrDate("datep", date);
|
||||
await setDolibarrDate("datev", date);
|
||||
await page.selectOption('select[name="accountid"]', account);
|
||||
await page.selectOption('select[name="sens"]', sens);
|
||||
await page.selectOption('select[name="paymenttype"]', paymentType).catch(() => {});
|
||||
|
||||
// Le sélecteur de compte comptable est appairé sur le NUMÉRO affiché
|
||||
// (« 613000 - Locations »), jamais sur l'id interne, qui diffère d'une
|
||||
// instance à l'autre.
|
||||
const opts = await page.locator('select[name="accountancy_code"] option').evaluateAll(
|
||||
(os: { textContent: string | null; getAttribute(n: string): string | null }[]) =>
|
||||
os.map((o) => ({ text: (o.textContent ?? "").trim(), value: o.getAttribute("value") ?? "" })),
|
||||
);
|
||||
const hit = opts.find((o) => new RegExp(`^${code}0*\\s*-`).test(o.text)) ??
|
||||
opts.find((o) => o.text.startsWith(code));
|
||||
if (!hit) {
|
||||
console.error(`aucun compte comptable ne commence par « ${code} ». Candidats 61x :`);
|
||||
for (const o of opts.filter((x) => /^6[12]/.test(x.text))) console.error(` ${o.text}`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
console.log(`compte retenu : ${hit.text}`);
|
||||
await page.selectOption('select[name="accountancy_code"]', hit.value);
|
||||
|
||||
await page.locator('input[name="save"], input[type="submit"]').first().click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const body = (await page.locator("body").innerText()).replace(/\s+/g, " ");
|
||||
const err = body.match(/Erreur[^.]{0,180}/i);
|
||||
if (err) {
|
||||
console.error(`\nÉCHEC — ${err[0]}`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
const found = await find(page, label, amount);
|
||||
if (!found) {
|
||||
console.error("\nNON CRÉÉE — l'écriture n'apparaît pas dans la liste.");
|
||||
Deno.exit(1);
|
||||
}
|
||||
console.log(`\ncréée et vérifiée : ${found.slice(0, 110)}`);
|
||||
} finally {
|
||||
await context.close();
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user