add dolibarr-tva-reconciliation, dolibarr-recurring-templates, dolibarr-data-snapshot
V3 bundle — three sibling skills under .claude/skills/, all read-only,
all depending on the dolibarr base skill.
dolibarr-tva-reconciliation:
- tva-by-month.sh: HT + TVA grouped by (year-month × tva_tx), ready
for CA3 / CA12 transcription.
- tva-line-detail.sh: per-line audit trail with country-based bucket
assignment (A1 domestic / A4 intra-UE autoliquidation / E2 export
hors UE). Documents the French TVA mental model.
- Today every Arcodange line is E2 (KissMetrics, US, autoliquidation
259-1° CGI). The skill scales for the day a French B2B is invoiced.
dolibarr-recurring-templates:
- list-templates.sh: probes /invoices/templates/{id} since there's no
list endpoint. Stops after 5 consecutive empty responses.
- inspect-template.sh: full audit per template, with health checks.
- Surfaces that the "Kiss Metrics Invoice" template has frequency=0
and nb_gen_done=0 — it is NOT auto-firing. Every KM invoice today
was manually duplicated. Cohort-review implication: the deferred
9-month cycle depends on Gabriel clicking "Generate" each month,
not on a Dolibarr cron.
dolibarr-data-snapshot:
- snapshot.sh: bundles every read endpoint the dolibarr-* family uses
into one JSON with a content_hash (sha256 of data only, excluding
timestamp — so identical state hashes identically across runs).
- Use cases: cohort evidence packs, drift detection, archival before
a known-risky UI change.
- V1 baseline summary captured at examples/snapshot-summary.txt
(the ~246 KB snapshot file itself is intentionally not committed).
Also extends dolibarr/SKILL.md endpoint catalogue with
/invoices/templates/{id} (and its no-list-endpoint quirk + the
id-null sentinel for missing ids), plus links to the three new
sibling skills.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
174
.claude/skills/dolibarr-data-snapshot/scripts/snapshot.sh
Executable file
174
.claude/skills/dolibarr-data-snapshot/scripts/snapshot.sh
Executable file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env bash
|
||||
# Snapshot the read-only state of the Arcodange Dolibarr into one JSON file.
|
||||
#
|
||||
# Usage:
|
||||
# snapshot.sh [--out PATH] # default: ./snapshot-YYYY-MM-DDTHHMMSS.json
|
||||
# snapshot.sh --print-only # write to stdout instead of a file
|
||||
#
|
||||
# The snapshot is content-addressable: it includes a SHA-256 of the
|
||||
# serialized payload (computed AFTER stable key-sorting) so two snapshots
|
||||
# of the same state hash identically. Useful for:
|
||||
# - cohort review evidence packs (sign + send)
|
||||
# - drift detection between dates (diff two snapshots)
|
||||
# - archival before a known-risky change
|
||||
#
|
||||
# What's included (everything the dolibarr-* family reads):
|
||||
# - status (Dolibarr version)
|
||||
# - thirdparties (full list + detail)
|
||||
# - invoices (full list + per-invoice detail + per-invoice payments)
|
||||
# - recurring invoice templates (probed 1..MAX_TEMPLATE_ID)
|
||||
# - products
|
||||
# - bank accounts
|
||||
#
|
||||
# Excluded by design:
|
||||
# - PDF attachments (binary, would bloat the snapshot ~50KB each)
|
||||
# - users/info (would leak ai_agent details)
|
||||
# - any non-read endpoints
|
||||
#
|
||||
# Requires: curl, jq, python3 (with hashlib — standard lib).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
|
||||
|
||||
MAX_TEMPLATE_ID=20
|
||||
EMPTY_TPL_TOLERANCE=5
|
||||
OUT=""
|
||||
PRINT_ONLY=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--out) OUT="$2"; shift 2 ;;
|
||||
--print-only) PRINT_ONLY=1; shift ;;
|
||||
--max-template-id) MAX_TEMPLATE_ID="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "snapshot.sh: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
WORK="$(mktemp -d -t dolsnap.XXXXXX)"
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
fetch_into() {
|
||||
local out_file="$1" path="$2"
|
||||
"${DOL_CURL}" "${path}" > "${out_file}" 2>/dev/null || {
|
||||
# On HTTP error, dol-curl prints body+stderr; capture body for record.
|
||||
"${DOL_CURL}" "${path}" > "${out_file}" 2>&1 || true
|
||||
}
|
||||
}
|
||||
|
||||
# 1. Liveness + status
|
||||
fetch_into "${WORK}/status.json" /status
|
||||
|
||||
# 2. Thirdparties (list + detail)
|
||||
fetch_into "${WORK}/tps_list.json" /thirdparties
|
||||
TP_IDS=$(python3 -c "
|
||||
import json,sys
|
||||
try: d = json.load(open(sys.argv[1]))
|
||||
except: d = []
|
||||
if isinstance(d, list): print(' '.join(str(t['id']) for t in d if t.get('id')))
|
||||
" "${WORK}/tps_list.json")
|
||||
mkdir -p "${WORK}/tps"
|
||||
for id in ${TP_IDS}; do fetch_into "${WORK}/tps/${id}.json" "/thirdparties/${id}"; done
|
||||
|
||||
# 3. Invoices (list + detail + payments)
|
||||
fetch_into "${WORK}/inv_list.json" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC'
|
||||
INV_IDS=$(python3 -c "
|
||||
import json,sys
|
||||
try: d = json.load(open(sys.argv[1]))
|
||||
except: d = []
|
||||
print(' '.join(str(i['id']) for i in d if i.get('id')))
|
||||
" "${WORK}/inv_list.json")
|
||||
mkdir -p "${WORK}/inv" "${WORK}/pay"
|
||||
for id in ${INV_IDS}; do
|
||||
fetch_into "${WORK}/inv/${id}.json" "/invoices/${id}"
|
||||
fetch_into "${WORK}/pay/${id}.json" "/invoices/${id}/payments"
|
||||
done
|
||||
|
||||
# 4. Recurring templates (probe)
|
||||
mkdir -p "${WORK}/tpl"
|
||||
CONSECUTIVE_EMPTY=0
|
||||
for tid in $(seq 1 "${MAX_TEMPLATE_ID}"); do
|
||||
fetch_into "${WORK}/tpl/${tid}.json" "/invoices/templates/${tid}"
|
||||
REAL=$(python3 -c "import json,sys
|
||||
try: d=json.load(open(sys.argv[1])); print('1' if d.get('id') else '0')
|
||||
except: print('0')" "${WORK}/tpl/${tid}.json")
|
||||
if [[ "${REAL}" == "1" ]]; then
|
||||
CONSECUTIVE_EMPTY=0
|
||||
else
|
||||
CONSECUTIVE_EMPTY=$((CONSECUTIVE_EMPTY+1))
|
||||
rm "${WORK}/tpl/${tid}.json"
|
||||
[[ ${CONSECUTIVE_EMPTY} -ge ${EMPTY_TPL_TOLERANCE} ]] && break
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Products + bank accounts
|
||||
fetch_into "${WORK}/products.json" /products
|
||||
fetch_into "${WORK}/bankaccounts.json" /bankaccounts
|
||||
|
||||
# 6. Compose the snapshot
|
||||
python3 - "${WORK}" <<'PY' > "${WORK}/snapshot.json"
|
||||
import json, os, sys, datetime, hashlib
|
||||
|
||||
work = sys.argv[1]
|
||||
|
||||
def load(path, default):
|
||||
try: return json.load(open(path))
|
||||
except (FileNotFoundError, json.JSONDecodeError): return default
|
||||
|
||||
def load_dir(dirname):
|
||||
out = {}
|
||||
full = os.path.join(work, dirname)
|
||||
if not os.path.isdir(full): return out
|
||||
for fn in sorted(os.listdir(full)):
|
||||
if not fn.endswith(".json"): continue
|
||||
key = fn[:-len(".json")]
|
||||
out[key] = load(os.path.join(full, fn), None)
|
||||
return out
|
||||
|
||||
data = {
|
||||
"status": load(os.path.join(work, "status.json"), {}),
|
||||
"thirdparties": {
|
||||
"list": load(os.path.join(work, "tps_list.json"), []),
|
||||
"detail": load_dir("tps"),
|
||||
},
|
||||
"invoices": {
|
||||
"list": load(os.path.join(work, "inv_list.json"), []),
|
||||
"detail": load_dir("inv"),
|
||||
"payments": load_dir("pay"),
|
||||
},
|
||||
"recurring_templates": load_dir("tpl"),
|
||||
"products": load(os.path.join(work, "products.json"), []),
|
||||
"bank_accounts": load(os.path.join(work, "bankaccounts.json"), []),
|
||||
}
|
||||
|
||||
# content_hash is the sha256 of `data` only — excludes timestamp + metadata,
|
||||
# so two snapshots of identical Dolibarr state hash identically.
|
||||
# (Drift detection is then: compare content_hash, done.)
|
||||
content_serialized = json.dumps(data, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
||||
content_hash = "sha256:" + hashlib.sha256(content_serialized).hexdigest()
|
||||
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"captured_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"instance": "erp.arcodange.lab",
|
||||
"content_hash": content_hash,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
|
||||
PY
|
||||
|
||||
# 7. Output
|
||||
if [[ "${PRINT_ONLY}" == "1" ]]; then
|
||||
cat "${WORK}/snapshot.json"
|
||||
else
|
||||
if [[ -z "${OUT}" ]]; then
|
||||
OUT="./snapshot-$(date -u +%Y-%m-%dT%H%M%SZ).json"
|
||||
fi
|
||||
cp "${WORK}/snapshot.json" "${OUT}"
|
||||
SIZE=$(stat -f %z "${OUT}" 2>/dev/null || stat -c %s "${OUT}")
|
||||
HASH=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['content_hash'])" "${OUT}")
|
||||
echo "wrote ${OUT} (${SIZE} bytes)"
|
||||
echo " ${HASH}"
|
||||
fi
|
||||
Reference in New Issue
Block a user