add dolibarr-thirdparty-completeness and dolibarr-tva-deductible
V4 bundle — two more sibling skills, both read-only, both depending on the dolibarr base skill. dolibarr-thirdparty-completeness: - audit-thirdparty.sh <socid>: country-aware completeness audit for any thirdparty (FR: SIREN + SIRET + tva_intra; EU non-FR: tva_intra; extra-EU: national tax id). Generalizes the V1 KM-hardcoded script. - audit-all-thirdparties.sh: loops over /thirdparties and surfaces a compact table of gaps. --clients-only / --suppliers-only flags. - Live baseline finds 5/10 thirdparties with mandatory gaps: KissMetrics (US tax id), Wise Europe SA (BE tva_intra), Medialex (FR SIRET + tva_intra), Qonto (SIRET), Infogreffe (SIRET). dolibarr-tva-deductible: - deductible-by-month.sh: TVA déductible aggregated per period × rate. - deductible-line-detail.sh: per supplier-invoice line with country- based CA3 bucket assignment (ligne 20 for 20 % FR, ligne 19 for reduced rates, ligne 17+24 for intra-UE autoliquidation). - Live baseline: 223.22 € total TVA déductible across 13 lines. Wise Europe SA correctly identified as intra-UE autoliquidation; La Poste correctly identified as FR exempt (timbres). - Mirrors dolibarr-tva-reconciliation on the supplier side. Together they give the two numbers a CA3 monthly declaration needs. Also extends dolibarr/SKILL.md endpoint catalogue with /supplierinvoices (noting the 403 on the /lines sub-endpoint — inline lines on the detail endpoint make this a non-issue). dolibarr/README.md gains two new permission checkboxes for Factures fournisseurs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# Audit every visible Arcodange thirdparty for completeness.
|
||||
#
|
||||
# Usage:
|
||||
# audit-all-thirdparties.sh [--clients-only] [--suppliers-only]
|
||||
#
|
||||
# Iterates /thirdparties, runs the per-id audit logic inline (one HTTP call
|
||||
# per thirdparty), prints a compact table: id, name, country, role,
|
||||
# mandatory-fail count, optional-unset count, top missing fields. Exits 0
|
||||
# only if every visible thirdparty has zero mandatory failures.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
|
||||
|
||||
FILTER="all"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--clients-only) FILTER="client"; shift ;;
|
||||
--suppliers-only) FILTER="supplier"; shift ;;
|
||||
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "audit-all-thirdparties.sh: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
WORK="$(mktemp -d -t dolaud.XXXXXX)"
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
"${DOL_CURL}" '/thirdparties?limit=500' > "${WORK}/list.json"
|
||||
IDS=$(python3 -c "
|
||||
import json,sys
|
||||
print(' '.join(str(t['id']) for t in json.load(open(sys.argv[1])) if t.get('id')))
|
||||
" "${WORK}/list.json")
|
||||
|
||||
mkdir -p "${WORK}/tp"
|
||||
for id in ${IDS}; do "${DOL_CURL}" "/thirdparties/${id}" > "${WORK}/tp/${id}.json"; done
|
||||
|
||||
python3 - "${WORK}" "${FILTER}" <<'PY'
|
||||
import json, sys, os
|
||||
work, filt = sys.argv[1], sys.argv[2]
|
||||
EU = set("AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split())
|
||||
|
||||
def audit(d):
|
||||
cnty = d.get("country_code") or ""
|
||||
is_client = str(d.get("client") or "0") in ("1","2","3")
|
||||
is_supplier = str(d.get("fournisseur") or "0") == "1"
|
||||
# Build the same rules as audit-thirdparty.sh (keep in sync)
|
||||
mandatory_missing = []
|
||||
for label, val, mandatory in [
|
||||
("name", d.get("name"), True),
|
||||
("address", d.get("address"), True),
|
||||
("zip", d.get("zip"), True),
|
||||
("town", d.get("town"), True),
|
||||
("country", d.get("country_code"), True),
|
||||
]:
|
||||
if mandatory and not (val not in (None, "", "0")): mandatory_missing.append(label)
|
||||
if cnty == "FR":
|
||||
for label, val in (("SIREN", d.get("idprof1")), ("SIRET", d.get("idprof2"))):
|
||||
if not val: mandatory_missing.append(label)
|
||||
if is_supplier and not d.get("tva_intra"):
|
||||
mandatory_missing.append("tva_intra")
|
||||
elif cnty in EU and cnty:
|
||||
if not d.get("tva_intra"): mandatory_missing.append("tva_intra")
|
||||
elif cnty:
|
||||
if not d.get("idprof1"): mandatory_missing.append("tax_id")
|
||||
role_bits = []
|
||||
if is_client: role_bits.append("client")
|
||||
if is_supplier: role_bits.append("supplier")
|
||||
role = "/".join(role_bits) or "-"
|
||||
return cnty, role, mandatory_missing
|
||||
|
||||
rows = []
|
||||
for fn in sorted(os.listdir(os.path.join(work, "tp")), key=lambda f: int(f[:-len(".json")])):
|
||||
try: d = json.load(open(os.path.join(work, "tp", fn)))
|
||||
except json.JSONDecodeError: continue
|
||||
cnty, role, missing = audit(d)
|
||||
if filt == "client" and "client" not in role: continue
|
||||
if filt == "supplier" and "supplier" not in role: continue
|
||||
rows.append((d.get("id"), d.get("name") or d.get("ref"), cnty, role, missing))
|
||||
|
||||
print(f"{'id':>3} {'name':<35} {'cnty':<4} {'role':<16} {'missing'}")
|
||||
print("-" * 110)
|
||||
fails = 0
|
||||
for iid, name, cnty, role, missing in rows:
|
||||
miss = ", ".join(missing) if missing else "(complete)"
|
||||
print(f"{iid:>3} {(name or '-')[:35]:<35} {cnty:<4} {role:<16} {miss}")
|
||||
if missing: fails += 1
|
||||
print("-" * 110)
|
||||
print(f"# {len(rows)} thirdparties audited, {fails} with mandatory gaps")
|
||||
sys.exit(0 if fails == 0 else 1)
|
||||
PY
|
||||
113
.claude/skills/dolibarr-thirdparty-completeness/scripts/audit-thirdparty.sh
Executable file
113
.claude/skills/dolibarr-thirdparty-completeness/scripts/audit-thirdparty.sh
Executable file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# Country-aware completeness audit for one Arcodange thirdparty.
|
||||
#
|
||||
# Usage:
|
||||
# audit-thirdparty.sh <socid>
|
||||
#
|
||||
# Replaces the V1 dolibarr-invoice-audit/scripts/audit-km-thirdparty.sh
|
||||
# (which hardcoded socid=1) with a generalized version that:
|
||||
# - Detects whether the thirdparty is a client / supplier / both
|
||||
# - Applies country-specific completeness rules
|
||||
# FR → SIREN (idprof1) + SIRET (idprof2) + tva_intra (if VAT-registered)
|
||||
# EU non-FR → tva_intra required for B2B autoliquidation
|
||||
# Extra-EU → EIN-equivalent in idprof1 (or note that there's no enforceable rule)
|
||||
# - Checks email / phone / url / IBAN where applicable
|
||||
#
|
||||
# Exits 0 if every applicable field is populated, 1 otherwise.
|
||||
|
||||
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 "audit-thirdparty.sh: missing socid. Usage: audit-thirdparty.sh <socid>" >&2
|
||||
exit 2
|
||||
fi
|
||||
SOCID="$1"
|
||||
|
||||
TMP_JSON="$(mktemp -t doltp.XXXXXX.json)"
|
||||
trap 'rm -f "${TMP_JSON}"' EXIT
|
||||
"${DOL_CURL}" "/thirdparties/${SOCID}" > "${TMP_JSON}"
|
||||
|
||||
python3 - "${TMP_JSON}" <<'PY'
|
||||
import json, sys
|
||||
with open(sys.argv[1]) as f:
|
||||
d = json.load(f)
|
||||
|
||||
if not d.get("id"):
|
||||
print(f"audit-thirdparty.sh: no thirdparty at id={sys.argv[1]}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
EU = set("AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split())
|
||||
|
||||
socid = d.get("id")
|
||||
name = d.get("name") or d.get("ref")
|
||||
cnty = d.get("country_code") or ""
|
||||
is_client = str(d.get("client") or "0") in ("1", "2", "3") # 1=client, 2=prospect, 3=both
|
||||
is_supplier = str(d.get("fournisseur") or "0") == "1"
|
||||
role_bits = []
|
||||
if is_client: role_bits.append("client")
|
||||
if is_supplier: role_bits.append("supplier")
|
||||
role = "/".join(role_bits) or "neither"
|
||||
|
||||
# What completeness rules apply depends on country + role
|
||||
def rules_for(cnty, is_client, is_supplier):
|
||||
rules = []
|
||||
# Universal
|
||||
rules.append(("name", d.get("name"), True))
|
||||
rules.append(("address", d.get("address"), True))
|
||||
rules.append(("zip", d.get("zip"), True))
|
||||
rules.append(("town", d.get("town"), True))
|
||||
rules.append(("country_code", d.get("country_code"), True))
|
||||
# Country-specific identifiers
|
||||
if cnty == "FR":
|
||||
rules.append(("idprof1 (SIREN)", d.get("idprof1"), True))
|
||||
rules.append(("idprof2 (SIRET)", d.get("idprof2"), True))
|
||||
rules.append(("idprof3 (APE)", d.get("idprof3"), False)) # nice-to-have
|
||||
rules.append(("tva_intra (VAT)", d.get("tva_intra"), is_supplier)) # mandatory if supplier
|
||||
elif cnty in EU and cnty:
|
||||
rules.append(("tva_intra (VAT EU)", d.get("tva_intra"), True)) # autoliquidation requires it
|
||||
rules.append(("idprof1 (national reg)", d.get("idprof1"), False))
|
||||
elif cnty:
|
||||
# Non-EU
|
||||
rules.append(("idprof1 (tax id / EIN)", d.get("idprof1"), True))
|
||||
rules.append(("idprof2", d.get("idprof2"), False))
|
||||
# Contact
|
||||
rules.append(("email", d.get("email"), False))
|
||||
rules.append(("phone", d.get("phone"), False))
|
||||
rules.append(("url", d.get("url"), False))
|
||||
return rules
|
||||
|
||||
rules = rules_for(cnty, is_client, is_supplier)
|
||||
|
||||
print("=" * 80)
|
||||
print(f" Thirdparty {socid} — {name} [{role}, country={cnty or '?'}]")
|
||||
print("=" * 80)
|
||||
|
||||
mandatory_fails = 0
|
||||
optional_fails = 0
|
||||
for label, value, mandatory in rules:
|
||||
ok = value not in (None, "", "0")
|
||||
flag = "OK" if ok else ("XX" if mandatory else "--")
|
||||
suffix = "" if mandatory else " (optional)"
|
||||
print(f" [{flag}] {label:<22} = {value!r}{suffix}")
|
||||
if not ok:
|
||||
if mandatory: mandatory_fails += 1
|
||||
else: optional_fails += 1
|
||||
|
||||
# Surface what bank account info we have, if any
|
||||
account_iban = d.get("iban") or ""
|
||||
if account_iban:
|
||||
print(f" [OK] iban = {account_iban!r}")
|
||||
elif is_client or is_supplier:
|
||||
print(f" [--] iban = (not set) (optional)")
|
||||
|
||||
ao = d.get("array_options") or {}
|
||||
if ao:
|
||||
print(f" array_options (extrafields): {ao}")
|
||||
|
||||
print()
|
||||
print(f" {len(rules) - mandatory_fails - optional_fails} pass, {mandatory_fails} mandatory fail(s), {optional_fails} optional unset")
|
||||
sys.exit(0 if mandatory_fails == 0 else 1)
|
||||
PY
|
||||
Reference in New Issue
Block a user