feat(erp): manifeste d'exercice, rejeu à blanc, gardes manquantes, trunk réconcilié #92

Merged
arcodange merged 6 commits from arcodange/compta-rails into main 2026-08-14 22:05:50 +02:00
9 changed files with 1702 additions and 37 deletions
+4
View File
@@ -1,3 +1,7 @@
# Ansible Collection - arcodange.erp # Ansible Collection - arcodange.erp
Documentation for the collection. Documentation for the collection.
```sh
ansible-playbook playbooks/recurrentBackup.yml -i ~/.ansible/collections/ansible_collections/arcodange/factory/inventory -vv
```
@@ -40,10 +40,16 @@ Le document le dit expressément, et les pièces sont produites sur demande. La
règle est donc *pouvoir répondre simplement en cas d'enquête*, jamais *anticiper règle est donc *pouvoir répondre simplement en cas d'enquête*, jamais *anticiper
l'enquête en publiant*. l'enquête en publiant*.
**Limite qui inverse la règle :** un flux financier entre la société et son **Limite qui inverse la règle — et elle est étroite :** ne se déclare, même non
dirigeant se déclare, même non demandé, dès lors que le document porte demandé, que ce qui rendrait le document **faux** si on le taisait. Le critère
précisément sur l'absence d'un tel flux. Taire ce qui contredirait le document est la véracité de ce qui est affirmé, pas l'exhaustivité.
n'est pas de la discrétion, c'est une omission.
Une attestation d'absence de rémunération n'a pas à mentionner une indemnité
d'occupation : celle-ci n'est pas une rémunération, elle ne contredit donc rien.
La mentionner, c'est publier une relation financière que personne n'a demandée —
et un lecteur pressé y verrait une rémunération là où il n'y en a pas. Une
première rédaction l'avait fait, en s'abritant derrière la transparence ; c'était
une erreur d'analyse : la discrétion et la sincérité ne s'opposaient pas ici.
### 2. Permanence des méthodes ### 2. Permanence des méthodes
File diff suppressed because it is too large Load Diff
+49 -24
View File
@@ -37,6 +37,29 @@ TMP_S3_SECRET="dolibarr-backup-s3-temp"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
# --- garde de cluster -------------------------------------------------------
# Ce script lit des secrets, crée des Jobs et peut RESTAURER une base. Lancé sur
# le mauvais contexte kubectl, il part sur l'infrastructure de quelqu'un d'autre.
# Le contexte courant d'une station de travail n'est pas une garantie : il suffit
# d'un `K config use-context` oublié. On l'épingle donc, et on vérifie une
# empreinte POSITIVE du homelab avant d'agir — mêmes garde-fous que
# ops/sandbox/sandbox-lifecycle.sh.
ERP_KUBE_CONTEXT="${ERP_KUBE_CONTEXT:-default}"
K() { kubectl --context "$ERP_KUBE_CONTEXT" "$@"; }
assert_arcodange_cluster() {
kubectl config get-contexts -o name 2>/dev/null | grep -qx "$ERP_KUBE_CONTEXT" \
|| die "kube-context '$ERP_KUBE_CONTEXT' n'existe pas (définir ERP_KUBE_CONTEXT)"
for ns in erp erp-sandbox "$S3_SRC_NS"; do
K get ns "$ns" >/dev/null 2>&1 \
|| die "le contexte '$ERP_KUBE_CONTEXT' n'a pas de namespace '$ns' — refus de s'y exécuter.
Ce script lit des secrets et peut restaurer une base ; il ne doit viser que le homelab Arcodange.
Contexte courant : '$(kubectl config current-context 2>/dev/null)'.
Définir ERP_KUBE_CONTEXT sur le contexte du homelab et réessayer."
done
log "garde de cluster OK — contexte '$ERP_KUBE_CONTEXT' (namespaces erp/erp-sandbox/$S3_SRC_NS)"
}
CMD="${1:-}"; shift || true CMD="${1:-}"; shift || true
ENV="prod"; KEY=""; KIND=""; YES=0 ENV="prod"; KEY=""; KIND=""; YES=0
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -72,11 +95,11 @@ SH
copy_s3_secret() { copy_s3_secret() {
command -v python3 >/dev/null || die "python3 required to copy the S3 secret without exposing it" command -v python3 >/dev/null || die "python3 required to copy the S3 secret without exposing it"
kubectl get secret "$S3_SRC_SECRET" -n "$S3_SRC_NS" -o json \ K get secret "$S3_SRC_SECRET" -n "$S3_SRC_NS" -o json \
| python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_S3_SECRET','namespace':'$NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_ENDPOINTS')}; print(json.dumps(d))" \ | python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_S3_SECRET','namespace':'$NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_ENDPOINTS')}; print(json.dumps(d))" \
| kubectl apply -f - >/dev/null | K apply -f - >/dev/null
} }
cleanup_secret() { kubectl delete secret "$TMP_S3_SECRET" -n "$NS" --ignore-not-found >/dev/null 2>&1 || true; } cleanup_secret() { K delete secret "$TMP_S3_SECRET" -n "$NS" --ignore-not-found >/dev/null 2>&1 || true; }
# b64-encode an in-container script (host vars already substituted by the caller) # b64-encode an in-container script (host vars already substituted by the caller)
b64() { printf '%s' "$1" | base64 | tr -d '\n'; } b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
@@ -87,8 +110,8 @@ run_backup() {
copy_s3_secret copy_s3_secret
log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/" log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/"
local B64; B64="$(b64 "$(cat "${SCRIPT_DIR}/../../chart/files/backup-job.sh")")" local B64; B64="$(b64 "$(cat "${SCRIPT_DIR}/../../chart/files/backup-job.sh")")"
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF K apply -f - >/dev/null <<EOF
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: { name: dolibarr-backup, namespace: $NS } metadata: { name: dolibarr-backup, namespace: $NS }
@@ -118,10 +141,10 @@ spec:
command: ["/bin/sh","-c"] command: ["/bin/sh","-c"]
args: ["echo $B64 | base64 -d | sh"] args: ["echo $B64 | base64 -d | sh"]
EOF EOF
kubectl wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \ K wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \
|| die "backup Job did not complete — kubectl logs -n $NS job/dolibarr-backup" || die "backup Job did not complete — K logs -n $NS job/dolibarr-backup"
kubectl logs -n "$NS" job/dolibarr-backup | sed 's/^/ /' K logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT cleanup_secret; trap - EXIT
log "Backup complete." log "Backup complete."
} }
@@ -135,8 +158,8 @@ echo "db/:"; S3 ls "s3://$BUCKET/$PREFIX/db/" || echo " (empty)"
echo "docs/:"; S3 ls "s3://$BUCKET/$PREFIX/docs/" || echo " (empty)" echo "docs/:"; S3 ls "s3://$BUCKET/$PREFIX/docs/" || echo " (empty)"
EOF EOF
)" )"
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF K apply -f - >/dev/null <<EOF
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: { name: dolibarr-backup-list, namespace: $NS } metadata: { name: dolibarr-backup-list, namespace: $NS }
@@ -153,9 +176,9 @@ spec:
command: ["/bin/sh","-c"] command: ["/bin/sh","-c"]
args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"] args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"]
EOF EOF
kubectl wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true K wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true
kubectl logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /' K logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /'
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT cleanup_secret; trap - EXIT
} }
@@ -167,8 +190,8 @@ run_restore() {
copy_s3_secret copy_s3_secret
local DEPLOY="$NS" # release/instance name == namespace (erp / erp-sandbox) local DEPLOY="$NS" # release/instance name == namespace (erp / erp-sandbox)
log "Restore ${KIND} on '${ENV}' from ${KEY} (scaling ${DEPLOY} to 0)" log "Restore ${KIND} on '${ENV}' from ${KEY} (scaling ${DEPLOY} to 0)"
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/dev/null 2>&1 || true K scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/dev/null 2>&1 || true
kubectl wait --for=delete pod -l app.kubernetes.io/instance="$NS" -n "$NS" --timeout=120s >/dev/null 2>&1 || true K wait --for=delete pod -l app.kubernetes.io/instance="$NS" -n "$NS" --timeout=120s >/dev/null 2>&1 || true
local SCRIPT VOLS="[]" MOUNTS="[]" local SCRIPT VOLS="[]" MOUNTS="[]"
if [[ "$KIND" == "db" ]]; then if [[ "$KIND" == "db" ]]; then
@@ -197,8 +220,8 @@ EOF
)" )"
fi fi
local B64; B64="$(b64 "$SCRIPT")" local B64; B64="$(b64 "$SCRIPT")"
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF K apply -f - >/dev/null <<EOF
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: { name: dolibarr-restore, namespace: $NS } metadata: { name: dolibarr-restore, namespace: $NS }
@@ -221,19 +244,21 @@ spec:
command: ["/bin/sh","-c"] command: ["/bin/sh","-c"]
args: ["echo $B64 | base64 -d | sh"] args: ["echo $B64 | base64 -d | sh"]
EOF EOF
if ! kubectl wait --for=condition=complete job/dolibarr-restore -n "$NS" --timeout=300s >/dev/null 2>&1; then if ! K wait --for=condition=complete job/dolibarr-restore -n "$NS" --timeout=300s >/dev/null 2>&1; then
kubectl logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /' K logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /'
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
die "restore Job did not complete" die "restore Job did not complete"
fi fi
kubectl logs -n "$NS" job/dolibarr-restore | sed 's/^/ /' K logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
log "Scaling ${DEPLOY} back to 1" log "Scaling ${DEPLOY} back to 1"
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT cleanup_secret; trap - EXIT
log "Restore complete." log "Restore complete."
} }
assert_arcodange_cluster
case "$CMD" in case "$CMD" in
backup) run_backup ;; backup) run_backup ;;
list) run_list ;; list) run_list ;;
+111 -1
View File
@@ -35,6 +35,14 @@ PG_IMAGE="postgres:16-alpine"
DOC_ROOT="/var/www/documents" # dolibarr_main_data_root DOC_ROOT="/var/www/documents" # dolibarr_main_data_root
TMP_PROD_SECRET="prod-db-ro-temp" # transient copy of prod creds, deleted on exit TMP_PROD_SECRET="prod-db-ro-temp" # transient copy of prod creds, deleted on exit
# ATTENTION — NE JAMAIS ÉDITER CE FICHIER PENDANT QU'IL S'EXÉCUTE.
# Bash lit ses scripts au fil de l'eau : modifier le fichier en cours de route
# décale les offsets et corrompt l'exécution. Constaté le 2026-08-14 — un
# refresh-from-prod en cours s'est mis à afficher les messages de blank_sandbox
# puis a fini sur « syntax error ». Le travail était fait, mais la trace mentait
# sur ce qui tournait : sur un script qui purge des bases, c'est inacceptable.
# Attendre la fin, ou éditer une copie.
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
@@ -173,9 +181,111 @@ sync_documents() {
log "Documents synced. (For a one-shot logo only, scope the tar to mycompany/logos.)" log "Documents synced. (For a one-shot logo only, scope the tar to mycompany/logos.)"
} }
# --- mode vierge -------------------------------------------------------------
# Vide la base du bac à sable pour qu'un rejeu d'exercice reparte de zéro.
# `refresh-from-prod` réimporterait précisément les défauts que le rejeu doit
# corriger : reconstruire suppose donc une base vide, pas iso-prod.
#
# CETTE COMMANDE N'EST QU'UNE ÉTAPE. Seule, elle laisse le bac à sable sans
# schéma — Dolibarr sert alors une page de login sur une base vide. La chaîne
# complète, déjà outillée dans test/ :
#
# 1. sandbox-lifecycle.sh blank --yes purge la base, retire install.lock
# 2. cd test && deno run -A main.ts pilote l'installeur (/install/), la
# société, l'affichage et les modules
# 3. touch install.lock dans le pod reverrouille (recette du test/README.md)
# 4. deno run -A provisionSandbox.ts agent d'écriture, droits, clé API
#
# CE QUI REND L'OPÉRATION SÛRE : elle est ANNULABLE. `refresh-from-prod` restaure
# un bac à sable iso-prod en trois minutes. On peut donc casser sans regret — et
# c'est la seule raison pour laquelle une commande qui détruit une base entière
# est acceptable ici.
#
# Elle ne vise QUE $SB_NS. Le namespace de production n'apparaît nulle part dans
# cette fonction, et la garde de cluster refuse tout contexte qui n'est pas le
# homelab.
blank_sandbox() {
[ "${1:-}" = "--yes" ] || die "blank détruit toutes les données du bac à sable — relancer avec --yes"
trap restore_state EXIT
log "Pause du self-heal ArgoCD (sinon le scale-to-0 est repris en quelques secondes)"
set_selfheal false
log "erp-sandbox à 0 réplique (accès exclusif à la base)"
K scale deploy erp-sandbox -n "$SB_NS" --replicas=0 >/dev/null
K wait --for=delete pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=120s >/dev/null 2>&1 || true
log "Job de purge (DROP OWNED, aucune restauration)"
K delete job sandbox-blank -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
K apply -f - >/dev/null <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: sandbox-blank, namespace: $SB_NS }
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 900
template:
spec:
restartPolicy: Never
containers:
- name: blank
image: $PG_IMAGE
env:
- { name: SB_PGUSER, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } } }
- { name: SB_PGPASSWORD, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } } }
- { name: PGHOST, value: "$PGHOST" }
- { name: PGSSLMODE, value: "disable" }
command: ["/bin/sh","-c"]
args:
- |
set -eu
Q() { PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -tAc "\$1"; }
# Ceinture ET bretelles : on refuse d'agir si la base courante n'est
# pas celle du bac à sable. Une purge sur la mauvaise base ne se
# rattrape pas par un refresh.
CUR=\$(Q 'select current_database()')
[ "\$CUR" = "$SB_DB" ] || { echo "ABORT: base courante '\$CUR' au lieu de $SB_DB"; exit 1; }
echo "cible=\$CUR role=$SB_ROLE"
AVANT=\$(Q "select count(*) from pg_tables where schemaname='public' and tablename like 'llx_%'")
echo "tables llx_ avant purge : \$AVANT"
PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -v ON_ERROR_STOP=1 \\
-c "DROP OWNED BY $SB_ROLE CASCADE;"
APRES=\$(Q "select count(*) from pg_tables where schemaname='public' and tablename like 'llx_%'")
echo "tables llx_ après purge : \$APRES"
[ "\$APRES" -eq 0 ] || { echo "ABORT: \$APRES tables subsistent — purge incomplète"; exit 1; }
echo "DONE."
EOF
K wait --for=condition=complete job/sandbox-blank -n "$SB_NS" --timeout=180s >/dev/null 2>&1 \
|| die "le Job de purge n'a pas abouti — voir : K logs -n $SB_NS job/sandbox-blank"
K logs -n "$SB_NS" job/sandbox-blank | sed 's/^/ /'
K delete job sandbox-blank -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
# Le verrou d'installation vit sur le VOLUME DOCUMENTS, que la purge de la base
# ne touche pas. Tant qu'il est là, Dolibarr saute l'installeur et sert une page
# de login sur un schéma inexistant : l'instance a l'air vivante et ne l'est pas.
# Le retirer laisse l'entrypoint de l'image réinstaller un schéma neuf.
log "Retrait du verrou d'installation (sinon Dolibarr sert un login sur une base sans schéma)"
K scale deploy erp-sandbox -n "$SB_NS" --replicas=1 >/dev/null
K wait --for=condition=ready pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=180s >/dev/null 2>&1 || true
POD=$(sb_pod)
[ -n "$POD" ] && K exec -n "$SB_NS" "$POD" -- rm -f /var/www/documents/install.lock >/dev/null 2>&1 || true
log "Redémarrage pour déclencher l'installation"
K rollout restart deploy erp-sandbox -n "$SB_NS" >/dev/null
K rollout status deploy erp-sandbox -n "$SB_NS" --timeout=300s >/dev/null 2>&1 || true
log "Réarmement du self-heal ArgoCD"
set_selfheal true
log "Base vidée et verrou retiré. Vérifier que le schéma se reconstruit :"
log " curl -s https://erp-sandbox.arcodange.lab/api/index.php/status"
log "Annulation à tout moment : $0 refresh-from-prod"
}
case "${1:-}" in case "${1:-}" in
refresh-from-prod) assert_arcodange_cluster; refresh_from_prod ;; refresh-from-prod) assert_arcodange_cluster; refresh_from_prod ;;
blank) assert_arcodange_cluster; shift; blank_sandbox "${1:-}" ;;
sync-documents) assert_arcodange_cluster; sync_documents ;; sync-documents) assert_arcodange_cluster; sync_documents ;;
refresh) assert_arcodange_cluster; refresh_from_prod; sync_documents ;; refresh) assert_arcodange_cluster; refresh_from_prod; sync_documents ;;
*) echo "usage: $0 {refresh-from-prod|sync-documents|refresh}" >&2; exit 2 ;; *) echo "usage: $0 {refresh-from-prod|sync-documents|refresh|blank --yes}" >&2; exit 2 ;;
esac esac
+2 -2
View File
@@ -6,8 +6,8 @@
"ville": "Evry Courcouronnes", "ville": "Evry Courcouronnes",
"pays": "France (FR)", "pays": "France (FR)",
"telephonePortable": "0664574539", "telephonePortable": "0664574539",
"siteWeb": "gitea.arcodange.duckdns.org", "siteWeb": "arcodange.fr",
"email": "[email protected]", "email": "[email protected]",
"logoFilePath": "$IMG/logo512.png" "logoFilePath": "$IMG/logo512.png"
}, },
"ID": { "ID": {
+245
View File
@@ -0,0 +1,245 @@
/*
Extrait d'une instance Dolibarr le MANIFESTE D'UN EXERCICE : la description
complète et ordonnée des faits qui le composent.
POURQUOI. Un exercice ouvert n'a d'engagement envers personne aucun compte
déposé, aucune liasse, aucun FEC remis. Tant qu'il en va ainsi, une erreur de
MODÉLISATION (mauvais compte, auxiliaire manquant, libellé illisible) se
corrige mieux en rejouant depuis les pièces qu'en la contournant : la piste
d'audit fiable demande que l'écriture reflète sa pièce, et rejouer resserre ce
lien une rustine le distend.
Ce fichier ne fait que LIRE. Il produit le manifeste ; le rejeu est un autre
outil, et il ne s'exécute que sur une cible vierge.
CE QUI EST REJOUABLE, ET CE QUI NE L'EST PAS. La ligne de partage n'est pas
technique, elle est juridique :
rejouable : factures fournisseur enregistrées, lignes bancaires, paiements
divers, charges sociales, tiers tout ce qui est interne ;
FIGÉ : toute pièce déjà remise à un tiers (facture client ENVOYÉE,
dépôt au greffe, déclaration transmise) et tout exercice clos.
Une facture partie chez le client existe chez lui : aucun rejeu ne l'efface.
Le manifeste les extrait quand même il décrit l'exercice mais les marque
`delivered: true`, et le rejeu devra les reproduire à l'identique plutôt que
les recalculer.
LE RÉFÉRENT DE VÉRIFICATION. Les mouvements bancaires sont la seule partie de
l'exercice qu'on ne peut pas réécrire : Qonto et Wise sont hors de Dolibarr.
Le manifeste les embarque donc intégralement, et le test d'acceptation d'un
rejeu est qu'ils soient retrouvés au centime et à la date.
Usage :
DENO_TLS_CA_STORE=system \
DOLIBARR_URL=https://erp.arcodange.lab DOLAPIKEY=$(cat test/....key) \
deno run -A test/extractExercise.ts --year 2026 --out manifest.json
`DENO_TLS_CA_STORE=system` n'est pas décoratif : les hôtes du lab portent un
certificat signé par une autorité interne, présente dans le trousseau système
mais absente du magasin que Deno embarque. Sans lui, `fetch` échoue sur
« invalid peer certificate: UnknownIssuer » alors que curl et Playwright,
qui lisent le trousseau système, passent sans rien dire.
*/
import "load_dotenv";
const argv = Deno.args;
const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d);
const base = pick("--url", Deno.env.get("DOLIBARR_URL") ?? "https://erp.arcodange.lab");
const key = Deno.env.get("DOLAPIKEY") ?? "";
const year = Number(pick("--year", "2026"));
const out = pick("--out", `manifest-${year}.json`);
/**
* Pièces volontairement écartées du rejeu, avec leur motif.
*
* Une exclusion n'est légitime que si la pièce N'A JAMAIS É REMISE À UN TIERS.
* Le motif est consigné dans le manifeste : un auditeur doit pouvoir lire
* pourquoi une référence manque, sans avoir à le deviner ni à nous croire.
*/
const EXCLUSIONS: Record<string, string> = {
"FAC001-CL00001":
"Référence malformée (CL00001 au lieu de CL0001001) — défaut de configuration du masque. "
+ "JAMAIS REMISE AU CLIENT : vérifié le 2026-08-14, aucune trace d'envoi ; KissMetrics a été "
+ "facturé via Wise en février 2026, et le document qu'il détient référence FAC001-CL0001001. "
+ "La séquence FAC reste continue (FAC001 à FAC008), la réémission portant elle aussi FAC001.",
"AVC001-CL0001001":
"Avoir émis le 24/02/2026 pour annuler FAC001-CL00001. Sans objet dès lors que la facture "
+ "annulée n'a jamais quitté la société : il ne documentait qu'un faux pas interne, non un "
+ "événement commercial. La séquence AVC devient vide.",
};
if (!key) {
console.error("DOLAPIKEY requis (lecture seule).");
Deno.exit(2);
}
async function api<T>(path: string): Promise<T | null> {
const r = await fetch(`${base}/api/index.php${path}`, { headers: { DOLAPIKEY: key } });
if (!r.ok) return null;
try {
return await r.json() as T;
} catch {
return null;
}
}
/** Dolibarr rend des epochs ; on les fige en ISO dans le fuseau du serveur. */
const iso = (ts: unknown): string | null => {
const n = Number(ts);
if (!n) return null;
return new Date(n * 1000).toISOString().slice(0, 10);
};
const inYear = (ts: unknown) => {
const d = iso(ts);
return !!d && d.startsWith(String(year));
};
type Any = Record<string, unknown>;
console.log(`source : ${base}`);
console.log(`exercice : ${year}\n`);
// --- tiers ------------------------------------------------------------------
const tiers = (await api<Any[]>("/thirdparties?limit=500") ?? []).map((t) => ({
id: t.id,
name: t.name,
code_client: t.code_client,
code_fournisseur: t.code_fournisseur,
client: t.client,
fournisseur: t.fournisseur,
country_id: t.country_id,
tva_intra: t.tva_intra,
code_compta: t.code_compta,
code_compta_fournisseur: t.code_compta_fournisseur,
}));
console.log(`tiers : ${tiers.length}`);
// --- factures client --------------------------------------------------------
// `delivered: true` : elles sont parties chez le client. Un rejeu les reproduit
// à l'identique — référence, date, montant — il ne les recalcule pas.
const invoices = [];
const excluded: Any[] = [];
for (const f of await api<Any[]>("/invoices?limit=500") ?? []) {
if (!inYear(f.date)) continue;
const motif = EXCLUSIONS[String(f.ref)];
if (motif) {
excluded.push({ ref: f.ref, date: iso(f.date), total_ttc: f.total_ttc, motif });
continue;
}
const payments = await api<Any[]>(`/invoices/${f.id}/payments`) ?? [];
invoices.push({
ref: f.ref,
socid: f.socid,
date: iso(f.date),
date_lim_reglement: iso(f.date_lim_reglement),
type: f.type,
total_ht: f.total_ht,
total_tva: f.total_tva,
total_ttc: f.total_ttc,
multicurrency_code: f.multicurrency_code,
statut: f.statut,
paye: f.paye,
delivered: true,
lines: (f.lines as Any[] ?? []).map((l) => ({
desc: l.desc, subprice: l.subprice, qty: l.qty, tva_tx: l.tva_tx, product_type: l.product_type,
})),
payments: payments.map((p) => ({ amount: p.amount, type: p.type, date: p.date, ref: p.ref })),
});
}
console.log(`factures client : ${invoices.length} (figées — déjà remises au client)`);
for (const e of excluded) console.log(` ⊘ exclue : ${e.ref} (${e.total_ttc} EUR) — ${String(e.motif).slice(0, 72)}`);
// --- factures fournisseur ---------------------------------------------------
const supplierInvoices = [];
for (const f of await api<Any[]>("/supplierinvoices?limit=500") ?? []) {
if (!inYear(f.date)) continue;
const payments = await api<Any[]>(`/supplierinvoices/${f.id}/payments`) ?? [];
supplierInvoices.push({
ref: f.ref,
ref_supplier: f.ref_supplier,
socid: f.socid,
date: iso(f.date),
total_ht: f.total_ht,
total_tva: f.total_tva,
total_ttc: f.total_ttc,
statut: f.statut,
paye: f.paye,
lines: (f.lines as Any[] ?? []).map((l) => ({
desc: l.desc, subprice: l.subprice, qty: l.qty, tva_tx: l.tva_tx, product_type: l.product_type,
})),
payments: payments.map((p) => ({ amount: p.amount, type: p.type, date: p.date, ref: p.ref })),
});
}
console.log(`factures fournisseur : ${supplierInvoices.length}`);
// --- comptes et mouvements bancaires ---------------------------------------
// LE RÉFÉRENT. Tout le reste se vérifie contre ces lignes.
const accounts = [];
let bankLineCount = 0;
for (const a of await api<Any[]>("/bankaccounts") ?? []) {
const lines = (await api<Any[]>(`/bankaccounts/${a.id}/lines?limit=1000`) ?? [])
.map((l) => ({
date: iso(l.datev ?? l.dateo),
amount: Number(l.amount),
label: l.label,
// Les libellés générés par Dolibarr — « SupplierInvoicePayment »,
// « CustomerInvoicePayment » — traversent le grand livre tels quels et y
// sont illisibles. Le rejeu doit les remplacer par le libellé de la pièce.
generic_label: /^\(?(Customer|Supplier)InvoicePayment\)?$/.test(String(l.label ?? "")),
num_releve: l.num_releve,
}))
.filter((l) => l.date)
.sort((x, y) => String(x.date).localeCompare(String(y.date)));
bankLineCount += lines.length;
accounts.push({
id: a.id,
label: a.label,
ref: a.ref,
account_number: a.account_number,
accountancy_journal: a.accountancy_journal,
currency_code: a.currency_code,
balance: Number(lines.reduce((s, l) => s + l.amount, 0).toFixed(2)),
lines,
});
}
console.log(`comptes bancaires : ${accounts.length} (${bankLineCount} mouvements — référent de vérification)`);
const generics = accounts.flatMap((a) => a.lines).filter((l) => l.generic_label).length;
const manifest = {
meta: {
exercice: year,
source: base,
extracted_at: new Date().toISOString(),
// Un exercice clos ne se rejoue pas : la clôture est la limite dure.
replayable: true,
note: "Manifeste d'exercice — description des faits, non des écritures comptables. "
+ "Les factures client sont marquées delivered:true : elles existent chez le client "
+ "et se reproduisent à l'identique, jamais ne se recalculent.",
},
exclusions: excluded,
anomalies: {
libelles_generiques: generics,
detail: generics
? "lignes bancaires portant un libellé Dolibarr générique — à remplacer au rejeu"
: "aucune",
},
tiers,
invoices,
supplierInvoices,
accounts,
};
await Deno.writeTextFile(out, JSON.stringify(manifest, null, 2));
const totalClient = invoices.reduce((s, f) => s + Number(f.total_ttc ?? 0), 0);
const totalFourn = supplierInvoices.reduce((s, f) => s + Number(f.total_ttc ?? 0), 0);
console.log(`\n--- récapitulatif ---`);
console.log(` produits facturés : ${totalClient.toFixed(2)} EUR`);
console.log(` achats facturés : ${totalFourn.toFixed(2)} EUR`);
for (const a of accounts) console.log(` ${String(a.label).padEnd(32)} ${a.balance.toFixed(2)} EUR`);
console.log(` libellés génériques : ${generics} ← à corriger au rejeu`);
console.log(`\nmanifeste écrit : ${out}`);
+11 -4
View File
@@ -2,6 +2,7 @@ import "load_dotenv";
import { chromium } from "playwright"; import { chromium } from "playwright";
import initialSetup from "./scripts/admin/initialSetup.ts"; import initialSetup from "./scripts/admin/initialSetup.ts";
import login from "./scripts/login.ts"; import login from "./scripts/login.ts";
import { assertSandbox } from "./scripts/guard.ts";
import companySetup, { Company } from "./scripts/admin/companySetup.ts"; import companySetup, { Company } from "./scripts/admin/companySetup.ts";
import path from "node:path"; import path from "node:path";
import displaySetup from "./scripts/admin/displaySetup.ts"; import displaySetup from "./scripts/admin/displaySetup.ts";
@@ -10,8 +11,14 @@ import moduleSetup from "./scripts/admin/moduleSetup.ts";
/* /*
Initialisation Initialisation
*/ */
const dolibarrAddress = Deno.env.get("DOLIBARR_ADDRESS") || // GARDE D'HÔTE. main.ts pilote l'ASSISTANT D'INSTALLATION (`/install/`) : c'est
"https://erp.arcodange.duckdns.org"; // le script le plus destructeur du dépôt. Pointé sur la production avec
// l'environnement ambiant — et `test/.env` porte justement DOLIBARR_ADDRESS sur
// la PRODUCTION — il relancerait l'installeur sur l'ERP réel.
// assertSandbox() n'autorise que le bac à sable, sauf double opt-in explicite
// (ARCO_ALLOW_PRODUCTION + ARCO_PROD_CONFIRM) pour l'installation initiale d'un
// nouvel environnement, qui reste un geste d'opérateur.
const dolibarrAddress = assertSandbox();
const debug = true; const debug = true;
const DBpassword = Deno.env.get("DOLI_DB_PASSWORD") || "undefined"; const DBpassword = Deno.env.get("DOLI_DB_PASSWORD") || "undefined";
const adminCredentials = { const adminCredentials = {
@@ -20,7 +27,7 @@ const adminCredentials = {
}; };
const rootFolderPath = Deno.env.get("ROOT_FOLDER") || const rootFolderPath = Deno.env.get("ROOT_FOLDER") ||
"/Users/gabrielradureau/Desktop/ARCODANGE/erp"; "/Users/gabrielradureau/Work/Arcodange/erp";
const imgFolderPath = Deno.env.get("IMG_FOLDER") || const imgFolderPath = Deno.env.get("IMG_FOLDER") ||
path.join(rootFolderPath, "static/img"); path.join(rootFolderPath, "static/img");
const configFolderPath = Deno.env.get("CONFIG_FOLDER") || const configFolderPath = Deno.env.get("CONFIG_FOLDER") ||
@@ -71,7 +78,7 @@ const globalCtx = {
try { try {
// await displaySetup.setupDisplay(globalCtx); // await displaySetup.setupDisplay(globalCtx);
await moduleSetup.configureModule(globalCtx, {moduleName: 'TIERS' ,enabled: false, onConfiguration: async () => { await moduleSetup.configureModule(globalCtx, {moduleName: 'TIERS' , enabled: true, onConfiguration: async () => {
await globalCtx.page.screenshot({path: 'save.png'}) await globalCtx.page.screenshot({path: 'save.png'})
}}); }});
} catch (error) { } catch (error) {
+151
View File
@@ -0,0 +1,151 @@
/*
Rejeu d'un exercice à partir de son manifeste — pour l'instant EN LECTURE SEULE.
CE QU'IL FAIT AUJOURD'HUI. Il calcule l'état que produirait le rejeu, corrections
comprises, et le confronte au RÉFÉRENT : les mouvements bancaires. Qonto et Wise
sont hors de Dolibarr, on ne peut pas les réécrire ; si le rejeu les reproduit au
centime et à la date, il décrit le même exercice. C'est le seul test qui vaille.
CE QU'IL NE FAIT PAS ENCORE, et pourquoi. Un rejeu en écriture suppose une
instance VIERGE. `ops/sandbox/sandbox-lifecycle.sh` ne sait que `refresh-from-prod`
un re-seed iso-prod, qui réimporte précisément les défauts qu'on veut corriger.
Écrire le rejeu sans cette capacité produirait des doublons, pas une reconstruction.
La capacité manquante est donc un préalable, pas un détail d'implémentation.
LES CORRECTIONS QUE LE REJEU APPLIQUERAIT. Elles sont déclarées ici, pas éparpillées
dans le code : chacune nomme le défaut, sa cause et son remède. C'est ce que lira
un comptable repreneur pour comprendre pourquoi le livre reconstruit diffère de
l'ancien.
Usage :
DENO_TLS_CA_STORE=system DOLAPIKEY= \
deno run -A test/replayExercise.ts --manifest /manifest.json [--url ]
*/
import "load_dotenv";
const argv = Deno.args;
const pick = (f: string, d = "") => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : d);
const manifestPath = pick("--manifest", "manifest-2026.json");
const base = pick("--url", Deno.env.get("DOLIBARR_URL") ?? "https://erp.arcodange.lab");
const key = Deno.env.get("DOLAPIKEY") ?? "";
type Any = Record<string, unknown>;
type Line = { date: string; amount: number; label: string; generic_label: boolean };
type Account = { id: string; label: string; balance: number; lines: Line[] };
const manifest = JSON.parse(await Deno.readTextFile(manifestPath)) as {
meta: Any; exclusions: Any[]; accounts: Account[];
invoices: Any[]; supplierInvoices: Any[]; tiers: Any[];
};
/**
* Corrections que le rejeu appliquerait. Déclaratives : le défaut, sa cause, son
* remède. Une correction sans cause écrite est une réécriture silencieuse.
*/
const CORRECTIONS = [
{
id: "libelles-generiques",
defaut: "20 des 37 mouvements bancaires portent « (SupplierInvoicePayment) » ou "
+ "« (CustomerInvoicePayment) », libellés générés par Dolibarr",
cause: "aucun libellé n'est fourni à l'enregistrement du règlement ; Dolibarr retombe "
+ "sur une clé de traduction",
remede: "libeller chaque règlement d'après sa pièce — référence du fournisseur ou du client, "
+ "objet, période",
portee: "grand livre : ces libellés le traversent tels quels et y sont illisibles pour un "
+ "repreneur comme pour un inspecteur",
},
{
id: "auxiliaire-indemnites",
defaut: "les 7 paiements divers d'indemnité d'occupation ne se journalisent pas",
cause: "compte auxiliaire vide sur la ligne de charge ; le journal de banque exige une "
+ "contrepartie identifiée (« Tiers inconnu et code comptable auxiliaire non défini »)",
remede: "renseigner subledger_account = 455110 — vérifié en sandbox, l'écriture journalise",
portee: "sans cela le compte courant reste hors du grand livre : 1 483,23 EUR absents",
},
{
id: "tva-ovh-non-deductible",
defaut: "FAF2025001 enregistrée 6,08 HT + 1,22 TVA, la TVA figurant en déductible "
+ "(CA3 ligne 20, période 2025-10)",
cause: "facture émise au nom personnel du fondateur, la société n'étant pas immatriculée ; "
+ "une facture adressée à un particulier n'ouvre aucun droit à déduction",
remede: "enregistrer 7,30 EUR TTC au taux 0 — la charge est reprise pour son montant TTC "
+ "(décision n° 3, reprise L. 210-6)",
portee: "TVA déductible de l'exercice : 353,76 → 352,54 EUR. Aucune déclaration déposée "
+ "(CA12 annuelle, exercice clos le 31/12) — la correction ne coûte rien",
},
] as const;
async function api<T>(path: string): Promise<T | null> {
const r = await fetch(`${base}/api/index.php${path}`, { headers: { DOLAPIKEY: key } });
if (!r.ok) return null;
try { return await r.json() as T; } catch { return null; }
}
const iso = (ts: unknown): string | null => {
const n = Number(ts);
return n ? new Date(n * 1000).toISOString().slice(0, 10) : null;
};
console.log(`manifeste : ${manifestPath}`);
console.log(`exercice : ${manifest.meta.exercice}`);
console.log(`cible : ${base}\n`);
// --- 1. le référent : la banque ---------------------------------------------
console.log("=== CONTRÔLE BANCAIRE — le rejeu reproduit-il les mouvements ? ===\n");
let totalAttendu = 0, totalTrouve = 0, ecarts = 0;
for (const a of manifest.accounts) {
const live = (await api<Any[]>(`/bankaccounts/${a.id}/lines?limit=1000`) ?? [])
.map((l) => ({ date: iso(l.datev ?? l.dateo), amount: Number(l.amount) }))
.filter((l) => l.date);
// Appariement par (date, montant) : ce que la banque impose, et rien de plus.
// Le libellé ne participe PAS à l'appariement — c'est justement ce qu'on corrige.
const reste = [...live];
const manquants: Line[] = [];
for (const m of a.lines) {
const i = reste.findIndex((l) => l.date === m.date && Math.abs(l.amount - m.amount) < 0.005);
if (i >= 0) reste.splice(i, 1); else manquants.push(m);
}
totalAttendu += a.lines.length;
totalTrouve += a.lines.length - manquants.length;
ecarts += manquants.length + reste.length;
const verdict = manquants.length === 0 && reste.length === 0 ? "conforme" : "ÉCART";
console.log(` ${a.label.padEnd(32)} ${String(a.lines.length).padStart(3)} mouvements ${a.balance.toFixed(2).padStart(10)} EUR ${verdict}`);
for (const m of manquants) console.log(` manquant à la cible : ${m.date} ${m.amount.toFixed(2)} ${m.label}`);
for (const r of reste) console.log(` en trop à la cible : ${r.date} ${r.amount.toFixed(2)}`);
}
console.log(`\n ${totalTrouve}/${totalAttendu} mouvements appariés à la date et au centime`
+ (ecarts ? `${ecarts} écart(s)` : " — aucun écart"));
// --- 2. ce que le rejeu changerait ------------------------------------------
console.log("\n=== CORRECTIONS QUE LE REJEU APPLIQUERAIT ===\n");
for (const c of CORRECTIONS) {
console.log(`${c.id}`);
console.log(` défaut : ${c.defaut}`);
console.log(` cause : ${c.cause}`);
console.log(` remède : ${c.remede}`);
console.log(` portée : ${c.portee}\n`);
}
// --- 3. ce que le rejeu écarterait ------------------------------------------
console.log("=== PIÈCES ÉCARTÉES ===\n");
for (const e of manifest.exclusions) {
console.log(`${e.ref} (${Number(e.total_ttc).toFixed(2)} EUR, ${e.date})`);
console.log(` ${String(e.motif).replace(/(.{92})/g, "$1\n ")}\n`);
}
// --- 4. verdict --------------------------------------------------------------
const genericCount = manifest.accounts.flatMap((a) => a.lines).filter((l) => l.generic_label).length;
console.log("=== VERDICT ===\n");
console.log(` manifeste complet vis-à-vis de la banque : ${ecarts === 0 ? "OUI" : "NON — " + ecarts + " écart(s)"}`);
console.log(` corrections à appliquer : ${CORRECTIONS.length}`);
console.log(` libellés à réécrire : ${genericCount}`);
console.log(` pièces écartées : ${manifest.exclusions.length}`);
console.log(`\n Le rejeu EN ÉCRITURE reste bloqué : il exige une instance vierge, que`);
console.log(` sandbox-lifecycle.sh ne sait pas produire (refresh-from-prod uniquement).`);
Deno.exit(ecarts === 0 ? 0 : 1);