[DTP-Worker] Sprint 4 · Générateur configuration e-CF DGII (Compupar) (ERPNext Backend · roadmap L51)

Facturation électronique dominicaine cross-cohérente workflow↔DocType↔RBAC :
émission sur état soumis, base Currency réelle, rôle compta-fiscaliste-ecf.
Anti-invention (#6) : RNC/ITBIS/TipoCambio/endpoints Compupar null (a_confirmer,
jamais sans source) ; seules les données de référence DGII encodées avec source.
Composeur e-NCF traçable (E+tipo(2)+seq(10)). 39 tests · 12 invariants · gate CI
(job fiscal-ecf-tests) · 241 tests de régression verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 07:08:55 +00:00
parent 71c1223cc3
commit 7117394651
16 changed files with 1678 additions and 1 deletions
@@ -0,0 +1,7 @@
"""Integration e-CF DGII (Compupar) · Sprint 4 · ERPNext Backend.
Package interne du generateur : connaissance des briques reutilisees (`deps`),
composition TRACABLE du numero e-NCF (`ncf`) et assemblage du bundle de hand-off
(`builder`). La resolution des roles reutilise le `RoleResolver` du module
`crm/workflow_vente` (zero duplication · #6) — importe cote CLI et builder.
"""
@@ -0,0 +1,117 @@
"""Assemblage du plan de configuration e-CF DGII depuis `ecf_spec.json`.
Entree : le contrat e-CF (types DGII en perimetre, evenements d'emission
referencant des `update_value` du workflow, des `base_field` du DocType Dossier
Vente et des `role_id` RBAC, field_map, references provider Compupar) + un
`RoleResolver` (id → nom Frappe issu de rbac_50_roles.json). Sortie : un bundle
deterministe (tri stable, aucun horodatage) → reproductible bit-a-bit a contrat
constant, donc diffable et re-generable en CI.
Contrainte #6 (zero invention) : aucun chiffre propre a OTO n'est ajoute — RNC
emetteur, taux ITBIS, TipoCambio et endpoints Compupar restent `null` tels que
dans le contrat. Le builder n'ajoute que la resolution des noms de role et un
manifeste de tracabilite (compte des valeurs restant a confirmer inclus).
"""
from __future__ import annotations
import os
import sys
from typing import Any
# Reutilisation (workflow #5) du module CRM voisin : le resolveur de roles
# (rbac_50_roles.json) est importe, jamais redefini ici.
_HERE = os.path.dirname(os.path.abspath(__file__))
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
_CRM = os.path.join(_DELIVERABLES, "crm")
if _CRM not in sys.path:
sys.path.insert(0, _CRM)
from workflow_vente.wflib.rbac import RoleResolver # noqa: E402
def _event_sort_key(ev: dict) -> tuple:
"""Ordre stable : par evenement de pipeline puis par role."""
return (ev["update_value"], ev.get("tipo_ecf") or "", ev["role_id"])
def _a_confirmer_count(spec: dict, events: list[dict]) -> int:
"""Compte deterministe des valeurs restant a confirmer (anti-invention #6)."""
n = 0
n += 1 if spec["emisor"].get("a_confirmer") else 0
n += 1 if spec["provider_config"].get("a_confirmer") else 0
n += 1 if spec["moneda"].get("a_confirmer") else 0
n += sum(1 for t in spec["taxes"] if t.get("a_confirmer") or t.get("taux_pct") is None)
n += sum(1 for e in events if e.get("a_confirmer") or e.get("tipo_ecf") is None)
return n
def build_bundle(spec: dict, resolver: RoleResolver) -> dict[str, Any]:
"""Transforme le contrat e-CF en plan de configuration + manifeste."""
events_spec = sorted(spec["emission_events"], key=_event_sort_key)
events: list[dict] = []
for ev in events_spec:
events.append({
"update_value": ev["update_value"],
"tipo_ecf": ev.get("tipo_ecf"), # jamais fabrique (#6)
"base_field": ev["base_field"],
"role_id": ev["role_id"],
"erpnext_role_name": resolver.erpnext_name(ev["role_id"]),
"libelle": ev["libelle"],
"a_confirmer": bool(ev.get("a_confirmer")),
})
tipos = sorted(spec["tipos_ecf"], key=lambda t: t["code"])
tipos_en_scope = [t["code"] for t in tipos if t.get("en_scope")]
plan = {
"name": spec["config_name"],
"provider": spec["provider"],
"devise_field": spec["devise_field"],
"emisor": spec["emisor"],
"provider_config": spec["provider_config"],
"e_ncf": spec["e_ncf"],
"tipos_ecf": tipos,
"tipos_en_scope": tipos_en_scope,
"formas_pago": sorted(spec["formas_pago"], key=lambda f: f["code"]),
"forma_pago_defaut": spec["forma_pago_defaut"],
"moneda": spec["moneda"],
"taxes": spec["taxes"],
"emission_events": events,
"field_map": spec["field_map"],
}
roles_used = sorted({ev["role_id"] for ev in events_spec})
manifest = {
"generated_from": "ecf_spec.json",
"rbac_source": "rbac_50_roles.json",
"workflow_source": "crm/workflow_vente/workflow_vente_spec.json",
"doctype_source": "crm/dossier_vente/doctype_spec.json",
"source_version": spec["version"],
"config_name": spec["config_name"],
"provider": spec["provider"],
"counts": {
"tipos_ecf": len(tipos),
"tipos_en_scope": len(tipos_en_scope),
"formas_pago": len(plan["formas_pago"]),
"emission_events": len(events),
"roles": len(roles_used),
"valeurs_a_confirmer": _a_confirmer_count(spec, events),
},
"roles_rbac_utilises": [
{"role_id": rid, "erpnext_role_name": resolver.erpnext_name(rid)}
for rid in roles_used
],
# Rappel anti-invention (#6) : aucun chiffre OTO n'est fixe en-repo. Le
# Fiscaliste eCF / la Direction renseignent RNC, ITBIS, TipoCambio et les
# endpoints Compupar (avec source) cote VPS avant toute emission reelle.
"note_anti_invention": (
"RNC emetteur, taux ITBIS, TipoCambio USD→DOP et endpoints/credentials "
"Compupar restent `null` (a_confirmer) jusqu'a confirmation Compta "
"(avec source · #6). Seules les donnees de reference DGII (types e-CF, "
"formes de paiement, format e-NCF) sont encodees, avec leur source."
),
}
return {"manifest": manifest, "ecf_plan": plan}
@@ -0,0 +1,40 @@
"""Reutilisation des briques deja livrees (workflow #5 · zero duplication).
L'integration e-CF DGII partage l'idiome anti-invention du reste du mandat. On
importe — jamais on ne duplique — :
- `is_filled` : la notion de « champ reellement rempli » (un placeholder ou un
`null` n'est pas rempli) commune au generateur Faisabilite.
- `CANONICAL` : les parametres canoniques CLAUDE.md #9/#10 (USD+DOP notamment).
AUCUN taux ITBIS / RNC / TipoCambio n'y figure → aucun n'est
fabrique ici.
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
sous-ensemble), pour valider le bundle SANS installation pip
(le gate CI Gitea Actions tourne sans reseau · CLAUDE.md #2).
Import par `sys.path` (comme `commlib/deps.py`) — une seule source de verite.
"""
from __future__ import annotations
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
# fiscal/ecf_dgii/ecflib → 05_deliverables_mvp
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
_GEN = os.path.join(_DELIVERABLES, "faisabilite", "generator")
_PUB = os.path.join(_DELIVERABLES, "publiciste")
for _p in (_GEN, _PUB):
if _p not in sys.path:
sys.path.insert(0, _p)
from genlib import model # type: ignore # noqa: E402
from lib import validator # type: ignore # noqa: E402
is_filled = model.is_filled
CANONICAL = model.CANONICAL
TEMPLATE_VERSION = model.TEMPLATE_VERSION
validate = validator.validate
__all__ = ["is_filled", "CANONICAL", "TEMPLATE_VERSION", "validate"]
@@ -0,0 +1,90 @@
"""Composition TRACABLE du numero e-NCF (anti-invention · CLAUDE.md #6).
Meme doctrine que `commlib/finance.py` / `banclib/finance.py` : une composition
transparente, reproductible et entierement sourcee n'est PAS une invention —
c'est de la modelisation auditable. Le e-NCF (Numero de Comprobante Fiscal
electronique) suit le format DGII :
e-NCF = "E" + tipoeCF (2 chiffres) + secuencia (10 chiffres) → 13 caracteres
La `secuencia` provient d'un rango autorise par la DGII (jamais fabriquee ici).
`compose_encf` reste donc `None` tant qu'un operande manque (placeholder, jamais
0-invente), et publie toujours sa FORMULE. Aucun tipo ni sequence n'est jamais
fabrique par defaut : le caller fournit des operandes reels (cote VPS/Compupar).
"""
from __future__ import annotations
import re
from typing import Any, Optional
from . import deps
is_filled = deps.is_filled
# Format e-NCF DGII (Ley 32-23) : E + tipo(2) + secuencia(10) = 13 caracteres.
ENCF_RE = re.compile(r"^E([0-9]{2})([0-9]{10})$")
FORMULE = "E + tipoeCF(2) + secuencia(10)"
def is_valid_encf(value: Any) -> bool:
"""True si `value` respecte exactement le format e-NCF DGII (13 car.)."""
return bool(is_filled(value) and ENCF_RE.match(str(value).strip()))
def parse_encf(value: Any) -> Optional[dict]:
"""Decompose un e-NCF valide → {prefix, tipo, secuencia}. None sinon."""
if not is_filled(value):
return None
m = ENCF_RE.match(str(value).strip())
if not m:
return None
return {"prefix": "E", "tipo": m.group(1), "secuencia": m.group(2)}
def _tipo(value: Any) -> Optional[str]:
"""Un code de type sur 2 chiffres, ou None si absent/mal forme (jamais fabrique)."""
if not is_filled(value):
return None
txt = str(value).strip()
return txt if re.fullmatch(r"[0-9]{2}", txt) else None
def _secuencia(value: Any) -> Optional[str]:
"""Une secuencia numerique → 10 chiffres zero-paddes, ou None si absente/invalide.
Ne fabrique jamais de sequence : `None` en entree reste `None` en sortie.
"""
if not is_filled(value):
return None
txt = str(value).strip()
if not re.fullmatch(r"[0-9]{1,10}", txt):
return None
return txt.zfill(10)
def compose_encf(tipo: Any, secuencia: Any) -> dict:
"""Compose un e-NCF tracable a partir d'operandes reels.
Retourne un dict publiant la formule, la valeur composee (None si un operande
manque — jamais fabriquee), et les champs manquants. Aucun defaut n'est
invente : `tipo`/`secuencia` absents ⇒ `e_ncf: None`, `incomplete: True`.
"""
t = _tipo(tipo)
s = _secuencia(secuencia)
manquants: list[str] = []
if t is None:
manquants.append("tipo")
if s is None:
manquants.append("secuencia")
e_ncf = f"E{t}{s}" if (t is not None and s is not None) else None
return {
"e_ncf": e_ncf,
"tipo": t,
"secuencia": s,
"formule": FORMULE,
"incomplete": bool(manquants),
"champs_manquants": manquants,
}