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>
131 lines
5.0 KiB
Bash
Executable File
131 lines
5.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Inspect one recurring invoice template end-to-end.
|
|
#
|
|
# Usage:
|
|
# inspect-template.sh <id>
|
|
#
|
|
# Prints the template's identification, schedule (frequency / unit /
|
|
# next-fire / last-fire / counts), customer, payment terms, lines, and
|
|
# a HEALTH section that calls out common issues:
|
|
# - frequency == 0 → template NOT actually firing (manual generation only)
|
|
# - nb_gen_done == 0 but children invoices exist → manual duplications
|
|
# - date_when in the past → overdue / paused
|
|
# - suspended == 1 → explicit pause
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
|
|
|
|
if [[ $# -lt 1 ]]; then
|
|
echo "inspect-template.sh: missing id. Usage: inspect-template.sh <id>" >&2
|
|
exit 2
|
|
fi
|
|
ID="$1"
|
|
|
|
WORK="$(mktemp -d -t inspect.XXXXXX)"
|
|
trap 'rm -rf "${WORK}"' EXIT
|
|
|
|
"${DOL_CURL}" "/invoices/templates/${ID}" > "${WORK}/template.json"
|
|
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/inv.json"
|
|
|
|
python3 - "${WORK}" "${ID}" <<'PY'
|
|
import json, sys, os, datetime, html
|
|
work, tid = sys.argv[1], sys.argv[2]
|
|
|
|
t = json.load(open(os.path.join(work, "template.json")))
|
|
if not t.get("id"):
|
|
print(f"inspect-template.sh: no template at id={tid}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
socid = t.get("socid") or ""
|
|
# Fetch the customer name if we can
|
|
tp_name = "-"
|
|
if socid:
|
|
import subprocess
|
|
try:
|
|
out = subprocess.check_output([
|
|
os.path.join(os.path.dirname(__file__) if False else os.environ.get("DOL_CURL","./dol-curl.sh"),
|
|
), f"/thirdparties/{socid}"
|
|
], stderr=subprocess.DEVNULL)
|
|
tp_name = json.loads(out).get("name") or "-"
|
|
except Exception: pass
|
|
|
|
freq_n = int(t.get("frequency") or 0)
|
|
freq_u = t.get("unit_frequency") or ""
|
|
nb_done = int(t.get("nb_gen_done") or 0)
|
|
nb_max = int(t.get("nb_gen_max") or 0)
|
|
date_when = t.get("date_when") or ""
|
|
date_last = t.get("date_last_gen") or ""
|
|
suspended = str(t.get("suspended") or "0") == "1"
|
|
auto_val = str(t.get("auto_validate") or "0") == "1"
|
|
gen_pdf = str(t.get("generate_pdf") or "0") == "1"
|
|
|
|
print("=" * 80)
|
|
print(f" Template {tid} — {t.get('ref') or t.get('title') or '(unnamed)'}")
|
|
print("=" * 80)
|
|
print(f" Customer : socid={socid}")
|
|
print(f" Schedule : frequency={freq_n} {freq_u} {'(OFF — manual generation only)' if freq_n == 0 else ''}")
|
|
print(f" Counts : generated={nb_done} / max={nb_max or 'unbounded'}")
|
|
print(f" Next fire date : {date_when or '(unset)'}")
|
|
print(f" Last fire date : {date_last or '(none)'}")
|
|
print(f" Suspended : {suspended}")
|
|
print(f" Auto-validate : {auto_val}")
|
|
print(f" Generate PDF : {gen_pdf}")
|
|
print(f" Payment terms : mode={t.get('mode_reglement_code') or '-'} cond={t.get('cond_reglement_code') or '-'} ({t.get('cond_reglement_doc') or '-'})")
|
|
print(f" Totals : HT={t.get('total_ht','-')} TVA={t.get('total_tva','-')} TTC={t.get('total_ttc','-')}")
|
|
print(f" Bank account : fk_account={t.get('fk_account') or '-'}")
|
|
|
|
print()
|
|
print(" Lines:")
|
|
for L in t.get("lines") or []:
|
|
desc = html.unescape(L.get("desc") or "")
|
|
print(f" - ref={L.get('product_ref','-')} qty={L.get('qty','-')} subprice={L.get('subprice','-')} tva={L.get('tva_tx','-')} HT={L.get('total_ht','-')}")
|
|
if desc:
|
|
for ln in desc.splitlines():
|
|
if ln.strip():
|
|
print(f" {ln.strip()}")
|
|
|
|
# Children: invoices that quote this template in note_private
|
|
invoices = json.load(open(os.path.join(work, "inv.json")))
|
|
title_ref = (t.get("ref") or t.get("title") or "").strip()
|
|
children = []
|
|
for inv in invoices:
|
|
np = (inv.get("note_private") or "").lower()
|
|
if title_ref and title_ref.lower() in np:
|
|
children.append(inv)
|
|
|
|
print()
|
|
print(f" Generated children (by note_private match on '{title_ref}'):")
|
|
if not children:
|
|
print(" (none found — surprising if nb_gen_done > 0 or if you expected this to fire)")
|
|
else:
|
|
for inv in sorted(children, key=lambda x: int(x.get("date") or 0)):
|
|
ts = int(inv.get("date") or 0)
|
|
dt = datetime.date.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else "-"
|
|
print(f" - {dt} id={inv['id']:>4} {inv['ref']:<24} HT={inv.get('total_ht','-'):>10} paye={inv.get('paye','-')}")
|
|
|
|
# Health
|
|
print()
|
|
print(" Health checks:")
|
|
issues = []
|
|
if freq_n == 0:
|
|
issues.append("frequency=0 — template is NOT auto-generating; every child was created manually")
|
|
if nb_done == 0 and children:
|
|
issues.append(f"nb_gen_done=0 but {len(children)} child invoice(s) match by note — they were duplicated, not auto-generated")
|
|
if date_when and date_when != "":
|
|
try:
|
|
when = datetime.date.fromisoformat(date_when[:10])
|
|
if when < datetime.date.today():
|
|
issues.append(f"date_when ({date_when[:10]}) is in the past — next fire is overdue or paused")
|
|
except ValueError: pass
|
|
if suspended:
|
|
issues.append("suspended=1 — template explicitly paused")
|
|
if not issues:
|
|
print(" [OK] Template looks consistent.")
|
|
for i in issues:
|
|
print(f" [!!] {i}")
|
|
|
|
sys.exit(0 if not issues else 1)
|
|
PY
|