/* É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(); }