feat(erp): annoter un objet figé — la date URSSAF portée par une pièce jointe #90

Merged
arcodange merged 1 commits from arcodange/annotate-charge into main 2026-08-13 23:58:07 +02:00
3 changed files with 207 additions and 0 deletions
@@ -32,6 +32,45 @@ réels payées personnellement par le gérant — le tiers y est le fournisseur,
jamais le gérant. Un modèle qui contredit celui déjà en place est presque jamais le gérant. Un modèle qui contredit celui déjà en place est presque
toujours le mauvais. toujours le mauvais.
## Une charge sociale est TOTALEMENT figée — écrire juste du premier coup
> [!CAUTION]
> Sur ce déploiement (Dolibarr 22.0.4 + PostgreSQL), **aucune écriture sur une
> charge sociale n'aboutit** : ni la date, ni le montant, **ni même la note
> publique**. Toutes échouent sur
> `ERROR 42601: multiple assignments to same column "fk_user_modif"` —
> `ChargeSociales::update()` affecte deux fois la même colonne, ce que MySQL
> tolère et PostgreSQL rejette. Suivi : [erp#87](https://gitea.arcodange.lab/arcodange-org/erp/issues/87).
**La règle générale — « si c'est déjà en production et sans incidence sur
l'exercice, annoter l'objet suffit » — reste juste, mais son canal habituel est
fermé ici.** Le champ note passe par le même `UPDATE`.
Ce qui fonctionne, parce que c'est un autre chemin : **attacher un document**.
Le téléversement n'est pas un `UPDATE` sur l'objet.
```bash
curl -s -X POST https://erp.arcodange.lab/api/index.php/documents/upload \
-H "DOLAPIKEY: $(cat test/.ai_agent_prod_prod_write.key)" \
-H 'Content-Type: application/json' -d @- <<JSON
{"filename":"NOTE-<sujet>-<date>.md","modulepart":"tax","subdir":"<ID DE LA CHARGE>",
"filecontent":"<BASE64>","fileencoding":"base64","overwriteifexists":1}
JSON
```
Vérifier sur `/compta/sociales/document.php?id=<ID>` que le compteur a bougé.
Première application : la charge n°1 (URSSAF 1re échéance) porte le 22/05, date
du *prélèvement*, quand l'échéance officielle est le 05/05 — l'écart, resté dans
le même mois et le même exercice, est porté par une note attachée plutôt que
forcé dans la donnée.
`test/annotateObject.ts` écrit la note publique d'un objet **et refuse
explicitement** quand le bug la bloque, au lieu de croire une page qui ré-affiche
le formulaire. Ce faux positif a bien failli passer : la page de retour contient
le texte soumis, donc un contrôle naïf « le texte est là » réussit sur un
enregistrement qui n'a jamais eu lieu. **Relire l'objet, jamais la page de
retour.** Le script reste utile sur les objets non affectés par erp#87.
## Le compte courant d'associé : deux usages à ne pas confondre ## Le compte courant d'associé : deux usages à ne pas confondre
Le compte bancaire **`CCA1` (id 3)** porte le numéro comptable **45511** et son Le compte bancaire **`CCA1` (id 3)** porte le numéro comptable **45511** et son
@@ -0,0 +1,39 @@
# Note de correction — date de la 1re échéance URSSAF 2026
**Objet :** charge sociale n°1 — « URSSAF 2026 — 1re échéance », 493,00 €
**Établie le :** 13 août 2026
## Ce que la charge porte, et ce qui fait foi
La charge est enregistrée au **22/05/2026**. Cette date est celle du
**prélèvement bancaire**, non celle de l'échéance.
La date d'**échéance officielle est le 05/05/2026**, portée par l'appel de
cotisations de l'URSSAF Île-de-France du 09/07/2026, n° TI 117 1583346778 4,
joint à cette même charge.
Le règlement a donc été effectué avec **17 jours de retard** sur l'échéance.
## Pourquoi la date n'a pas été corrigée
Sur ce déploiement (Dolibarr 22.0.4 + PostgreSQL), **aucune modification d'une
charge sociale n'aboutit** — ni la date, ni le montant, ni la note publique.
Toute écriture échoue sur :
ERROR 42601: multiple assignments to same column "fk_user_modif"
`ChargeSociales::update()` affecte deux fois la même colonne ; MySQL l'accepte,
PostgreSQL le rejette. Suivi : erp#87.
L'écart restant dans le **même mois et le même exercice**, il est sans incidence
sur l'exercice 2026. Conformément à la doctrine append-only du registre, la
donnée n'est pas forcée : **la présente note et le justificatif joint font foi.**
## Rappel sur les montants
Les **3 041 €** appelés au titre de 2026 sont **provisoires** : ils sont calculés
sur la base forfaitaire applicable aux assurés en début d'activité (8 856 €) et
seront **régularisés** après la déclaration des revenus 2026.
Échéancier officiel : 0 € au 05/02 · 493 € au 05/05 · 1 215 € au 05/08 ·
1 333 € au 05/11 (dont 120 € de contribution à la formation professionnelle).
+129
View File
@@ -0,0 +1,129 @@
/*
Écrit la note publique d'un objet Dolibarr, par l'onglet Notes.
POURQUOI CE CHEMIN EXISTE. Certains objets ne sont plus modifiables sur ce
déploiement : toute soumission du formulaire d'édition d'une charge sociale
échoue sur `ERROR 42601: multiple assignments to same column "fk_user_modif"`
(erp#87). La NOTE, elle, passe par `update_note()` un UPDATE ciblé, une seule
affectation et **échappe au bug**. Vérifié en sandbox le 2026-08-13.
D'où la doctrine, posée par l'opérateur : quand une donnée fautive est déjà en
production et qu'elle n'a pas d'incidence insurmontable sur l'exercice, on ne
force pas la correction **on annote l'objet**. Le registre reste
append-only, la vérité est portée par la pièce et par la note, et l'écriture
n'est pas maquillée.
Deux pièges :
- le lien d'édition de la note est un `a.editfielda`, VISIBLE AU SURVOL
seulement : le cliquer expire. On lit son href il porte le token de
session et on y navigue directement ;
- la note s'écrase. Le script refuse d'écrire par-dessus une note existante
différente, sauf --force : personne ne doit perdre une annotation
antérieure sans l'avoir voulu.
Usage :
deno run -A test/annotateObject.ts --module compta/sociales --id 1 \
--note "…" [--force] [--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 mod = pick("--module", "compta/sociales"); // chemin du module Dolibarr
const id = pick("--id");
const note = pick("--note");
const force = argv.includes("--force");
const dryRun = argv.includes("--dry-run");
if (!id || !note) {
console.error("--id et --note sont requis");
Deno.exit(2);
}
const dolibarrAddress = assertSandbox();
console.log(`cible : ${dolibarrAddress}`);
console.log(`objet : /${mod}/note.php?id=${id}`);
console.log(`note : ${note.slice(0, 120)}${note.length > 120 ? "…" : ""}`);
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();
const normalise = (s: string) => s.replace(/\s+/g, " ").trim();
try {
await login.doAdminLogin({
page,
dolibarrAddress,
adminCredentials: {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
},
});
const notePage = `${dolibarrAddress}/${mod}/note.php?id=${id}`;
await page.goto(notePage);
if (!page.url().includes("note.php")) {
console.error(`objet introuvable — ${notePage} a redirigé vers ${page.url()}`);
Deno.exit(1);
}
const before = normalise(await page.locator("body").innerText());
if (before.includes(normalise(note).slice(0, 60))) {
console.log("note déjà présente à l'identique, rien à faire.");
Deno.exit(0);
}
// Le lien d'édition porte le token de session ; on le lit plutôt que de le
// cliquer, car il n'est rendu visible qu'au survol.
const href = await page.locator('a[href*="action=editnote_public"]').first()
.getAttribute("href").catch(() => null);
if (!href) {
console.error("aucun lien d'édition de note public — droits insuffisants, ou module sans note.");
Deno.exit(1);
}
await page.goto(dolibarrAddress + href);
const area = page.locator("form textarea").first();
if (!(await area.count())) {
console.error("pas de zone de saisie sur la page d'édition.");
Deno.exit(1);
}
const existing = normalise(await area.inputValue());
if (existing && !force) {
console.error(`une note différente existe déjà — refus d'écraser sans --force :\n ${existing.slice(0, 200)}`);
Deno.exit(1);
}
await area.fill(note);
await Promise.all([
page.waitForNavigation({ waitUntil: "networkidle" }).catch(() => null),
page.locator('input[type="submit"]').first().click(),
]);
const after = normalise(await page.locator("body").innerText());
const dup = after.match(/multiple assignments to same column '?"?(\w+)/);
if (dup) {
console.error(`\nBLOQUÉ — le bug amont touche aussi ce chemin (colonne ${dup[1]}). Voir erp#87.`);
Deno.exit(1);
}
// Relire la page de note plutôt que de croire la page de retour.
await page.goto(notePage);
const check = normalise(await page.locator("body").innerText());
if (!check.includes(normalise(note).slice(0, 60))) {
console.error("\nNON ENREGISTRÉE — la note n'apparaît pas après relecture.");
Deno.exit(1);
}
console.log("\nnote enregistrée et relue sur l'objet.");
} finally {
await context.close();
await browser.close();
}