[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:
@@ -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}
|
||||
Reference in New Issue
Block a user