feat(erp): manifeste d'exercice, rejeu à blanc, gardes manquantes, trunk réconcilié (#92)
Co-authored-by: Gabriel Radureau <[email protected]>
This commit was merged in pull request #92.
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
# Ansible Collection - arcodange.erp
|
||||
|
||||
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
|
||||
l'enquête en publiant*.
|
||||
|
||||
**Limite qui inverse la règle :** un flux financier entre la société et son
|
||||
dirigeant se déclare, même non demandé, dès lors que le document porte
|
||||
précisément sur l'absence d'un tel flux. Taire ce qui contredirait le document
|
||||
n'est pas de la discrétion, c'est une omission.
|
||||
**Limite qui inverse la règle — et elle est étroite :** ne se déclare, même non
|
||||
demandé, que ce qui rendrait le document **faux** si on le taisait. Le critère
|
||||
est la véracité de ce qui est affirmé, pas l'exhaustivité.
|
||||
|
||||
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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,29 @@ TMP_S3_SECRET="dolibarr-backup-s3-temp"
|
||||
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
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
|
||||
ENV="prod"; KEY=""; KIND=""; YES=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -72,11 +95,11 @@ SH
|
||||
|
||||
copy_s3_secret() {
|
||||
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))" \
|
||||
| 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() { printf '%s' "$1" | base64 | tr -d '\n'; }
|
||||
@@ -87,8 +110,8 @@ run_backup() {
|
||||
copy_s3_secret
|
||||
log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/"
|
||||
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
|
||||
kubectl apply -f - >/dev/null <<EOF
|
||||
K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
K apply -f - >/dev/null <<EOF
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata: { name: dolibarr-backup, namespace: $NS }
|
||||
@@ -118,10 +141,10 @@ spec:
|
||||
command: ["/bin/sh","-c"]
|
||||
args: ["echo $B64 | base64 -d | sh"]
|
||||
EOF
|
||||
kubectl 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"
|
||||
kubectl logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
|
||||
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
K wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \
|
||||
|| die "backup Job did not complete — K logs -n $NS job/dolibarr-backup"
|
||||
K logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
|
||||
K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
cleanup_secret; trap - EXIT
|
||||
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)"
|
||||
EOF
|
||||
)"
|
||||
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
kubectl apply -f - >/dev/null <<EOF
|
||||
K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
K apply -f - >/dev/null <<EOF
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata: { name: dolibarr-backup-list, namespace: $NS }
|
||||
@@ -153,9 +176,9 @@ spec:
|
||||
command: ["/bin/sh","-c"]
|
||||
args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"]
|
||||
EOF
|
||||
kubectl 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/^/ /'
|
||||
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
K wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true
|
||||
K logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /'
|
||||
K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
cleanup_secret; trap - EXIT
|
||||
}
|
||||
|
||||
@@ -167,8 +190,8 @@ run_restore() {
|
||||
copy_s3_secret
|
||||
local DEPLOY="$NS" # release/instance name == namespace (erp / erp-sandbox)
|
||||
log "Restore ${KIND} on '${ENV}' from ${KEY} (scaling ${DEPLOY} to 0)"
|
||||
kubectl 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 scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/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="[]"
|
||||
if [[ "$KIND" == "db" ]]; then
|
||||
@@ -197,8 +220,8 @@ EOF
|
||||
)"
|
||||
fi
|
||||
local B64; B64="$(b64 "$SCRIPT")"
|
||||
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
kubectl apply -f - >/dev/null <<EOF
|
||||
K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
K apply -f - >/dev/null <<EOF
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata: { name: dolibarr-restore, namespace: $NS }
|
||||
@@ -221,19 +244,21 @@ spec:
|
||||
command: ["/bin/sh","-c"]
|
||||
args: ["echo $B64 | base64 -d | sh"]
|
||||
EOF
|
||||
if ! kubectl 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/^/ /'
|
||||
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
|
||||
if ! K wait --for=condition=complete job/dolibarr-restore -n "$NS" --timeout=300s >/dev/null 2>&1; then
|
||||
K logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /'
|
||||
K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
|
||||
die "restore Job did not complete"
|
||||
fi
|
||||
kubectl logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
|
||||
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
K logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
|
||||
K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
|
||||
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
|
||||
log "Restore complete."
|
||||
}
|
||||
|
||||
assert_arcodange_cluster
|
||||
|
||||
case "$CMD" in
|
||||
backup) run_backup ;;
|
||||
list) run_list ;;
|
||||
|
||||
@@ -35,6 +35,14 @@ PG_IMAGE="postgres:16-alpine"
|
||||
DOC_ROOT="/var/www/documents" # dolibarr_main_data_root
|
||||
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' "$*"; }
|
||||
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.)"
|
||||
}
|
||||
|
||||
# --- 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
|
||||
refresh-from-prod) assert_arcodange_cluster; refresh_from_prod ;;
|
||||
blank) assert_arcodange_cluster; shift; blank_sandbox "${1:-}" ;;
|
||||
sync-documents) assert_arcodange_cluster; 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
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"ville": "Evry Courcouronnes",
|
||||
"pays": "France (FR)",
|
||||
"telephonePortable": "0664574539",
|
||||
"siteWeb": "gitea.arcodange.duckdns.org",
|
||||
"email": "[email protected]",
|
||||
"siteWeb": "arcodange.fr",
|
||||
"email": "[email protected]",
|
||||
"logoFilePath": "$IMG/logo512.png"
|
||||
},
|
||||
"ID": {
|
||||
|
||||
@@ -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 là où 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 ÉTÉ 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
@@ -2,6 +2,7 @@ import "load_dotenv";
|
||||
import { chromium } from "playwright";
|
||||
import initialSetup from "./scripts/admin/initialSetup.ts";
|
||||
import login from "./scripts/login.ts";
|
||||
import { assertSandbox } from "./scripts/guard.ts";
|
||||
import companySetup, { Company } from "./scripts/admin/companySetup.ts";
|
||||
import path from "node:path";
|
||||
import displaySetup from "./scripts/admin/displaySetup.ts";
|
||||
@@ -10,8 +11,14 @@ import moduleSetup from "./scripts/admin/moduleSetup.ts";
|
||||
/*
|
||||
Initialisation
|
||||
*/
|
||||
const dolibarrAddress = Deno.env.get("DOLIBARR_ADDRESS") ||
|
||||
"https://erp.arcodange.duckdns.org";
|
||||
// GARDE D'HÔTE. main.ts pilote l'ASSISTANT D'INSTALLATION (`/install/`) : c'est
|
||||
// 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 DBpassword = Deno.env.get("DOLI_DB_PASSWORD") || "undefined";
|
||||
const adminCredentials = {
|
||||
@@ -20,7 +27,7 @@ const adminCredentials = {
|
||||
};
|
||||
|
||||
const rootFolderPath = Deno.env.get("ROOT_FOLDER") ||
|
||||
"/Users/gabrielradureau/Desktop/ARCODANGE/erp";
|
||||
"/Users/gabrielradureau/Work/Arcodange/erp";
|
||||
const imgFolderPath = Deno.env.get("IMG_FOLDER") ||
|
||||
path.join(rootFolderPath, "static/img");
|
||||
const configFolderPath = Deno.env.get("CONFIG_FOLDER") ||
|
||||
@@ -71,7 +78,7 @@ const globalCtx = {
|
||||
|
||||
try {
|
||||
// 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'})
|
||||
}});
|
||||
} catch (error) {
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user