diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6bc3b22..c55573d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -237,10 +237,31 @@ jobs: - name: Tests générateur barème commissions run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Intégration e-CF DGII (Sprint 4 · ERPNext Backend « e-CF DGII intégration + # (Compupar) »). Génère un plan de configuration de facturation électronique + # dominicaine cross-cohérent : chaque émission se déclenche sur un état SOUMIS + # du workflow, sur un champ Currency réel du Dossier Vente, par le rôle Compta + # Fiscaliste eCF résolu. Anti-invention (#6) : RNC / ITBIS / TipoCambio / + # endpoints Compupar restent null (a_confirmer) — jamais fabriqués ; composeur + # d'e-NCF traçable (E+tipo(2)+seq(10)). Schéma de sortie + 12 invariants + + # FormaPago Cardnet (#10). stdlib pur, zéro pip. + # -------------------------------------------------------------------------- + fiscal-ecf-tests: + name: Fiscal · e-CF DGII (Compupar) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/fiscal/ecf_dgii + steps: + - uses: actions/checkout@v4 + - name: Tests générateur e-CF DGII + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md index b85f559..4811bca 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,50 @@ # Activity Log · 2026-07-30 · Claude Code DTP +## Session `20260730_065711` (session 14) + +**Tâche** : Sprint 4 · ERPNext Backend — Générateur de **configuration e-CF DGII +(Compupar)** (roadmap ligne 51 « e-CF DGII intégration (Compupar) » ; les +commissions ayant été livrées session 13, l'e-CF restait ouvert · GAP §Backend). + +**Fichiers créés** — `05_deliverables_mvp/fiscal/ecf_dgii/` : +- `ecf_spec.json` (catalogue 10 types e-CF DGII · table FormaPago · format e-NCF · + moneda USD/DOP · RNC/ITBIS/TipoCambio `null` · provider Compupar · 2 évènements + d'émission · `field_map` Dossier Vente → e-CF) +- `ecflib/{__init__,deps,ncf,builder}.py` (réutilise `is_filled`/`CANONICAL`/ + `validate` + `RoleResolver` du module `workflow_vente` · composeur e-NCF + traçable `E+tipo(2)+seq(10)` façon `finance.py`) +- `ecf_dgii_gen.py` (CLI `build`/`validate` · 12 invariants de cross-cohérence) +- `ecf.schema.json` (contrat de sortie draft-07) +- `fixtures/dossier_exemple.json` (test only · opérandes fictifs sourcés) +- `out/{ecf_plan,MANIFEST}.json` (hand-off) · `tests/test_ecf_dgii.py` + (39 tests) · `README.md` · `.gitignore` + +**Fichiers modifiés** : +- `.gitea/workflows/ci.yml` : job `fiscal-ecf-tests` + ajout au `gate`. + +**Anti-invention (cœur · #6)** : aucun chiffre fiscal OTO n'est documenté → +`rnc_emisor` / `taux ITBIS` / `TipoCambio` / endpoints Compupar restent `null` +(a_confirmer) ; un invariant refuse toute valeur fixée **sans `source`**. Seules +les données de référence DGII (types e-CF, FormaPago, format e-NCF) sont encodées, +avec source. Le composeur e-NCF reste `None` tant qu'un opérande manque. + +**Cross-cohérence** : chaque émission se déclenche sur un état **soumis** du +workflow (réservation/contrat), sur un champ **Currency** réel du Dossier Vente, +par le rôle **compta** `compta-fiscaliste-ecf` résolu depuis RBAC ; FormaPago +défaut = 3 (Tarjeta) car **Cardnet** (#10). + +**Vérifs** : 39/39 tests ; gate CI local vert (guard + JSON + docs + YAML) ; +régression **241 tests verts** au total. + +**Hors périmètre worker (VPS · #8)** : confirmation RNC/ITBIS/TipoCambio par la +Compta + configuration Compupar (endpoints/certificat/credentials) + câblage sur +les transitions Workflow → agent ERPNext Backend. + +**Détail complet** : voir +[`05_deliverables_mvp/daily_reports/2026-07-30-session14.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session14.md). + +**Auto-score 4Big** : 96/100. + ## Session `20260730_062706` (session 13) **Tâche** : Sprint 4 · ERPNext Backend — Générateur du **barème de commissions diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session14.md b/05_deliverables_mvp/daily_reports/2026-07-30-session14.md new file mode 100644 index 0000000..eb36046 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session14.md @@ -0,0 +1,94 @@ +# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 14) + +**Session** : `20260730_065711` + +## Tâche exécutée +**Sprint 4 · ERPNext Backend — Générateur de configuration e-CF DGII (Compupar)** +(roadmap ligne 51 : _« e-CF DGII intégration (Compupar) »_). C'est exactement la +« prochaine tâche suggérée » de la session 13. Le module produit un **plan de +configuration** de la facturation électronique dominicaine, cross-cohérent avec +les trois contrats CRM déjà livrés (pipeline vente + DocType porteur + RBAC), et +un **composeur d'e-NCF traçable** `E + tipoeCF(2) + secuencia(10)`. + +## Contexte / analyse +- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, les daily reports sessions 11-13 + et l'idiome des générateurs (`wflib`/`dvlib`/`commlib` : `deps.py` réutilisé + + `builder.py` + schéma draft-07 + CLI `build|validate` + tests + job CI + gate). +- Ligne 51 = _« e-CF DGII intégration (Compupar) **+** commissions vendeurs + auto »_ ; les commissions ont été livrées session 13, l'e-CF restait ouvert + (`GAP_ANALYSIS_SPRINT1.md` §Backend : _« e-CF DGII (Compupar) non intégré »_). +- **RBAC** : rôle dédié déjà présent — `compta-fiscaliste-ecf` (_OTO Compta + Fiscaliste eCF_, portail `compta`) → aucun rôle inventé. +- **Constat anti-invention (#6)** : aucun chiffre fiscal OTO n'est documenté (RNC + émetteur, taux ITBIS, TipoCambio). Les fixer serait une invention → tous `null` + (`a_confirmer`), avec un invariant qui **refuse** toute valeur fixée sans + `source`. Seules les **données de référence DGII** (codes de type e-CF, table + FormaPago, format e-NCF) sont encodées — identifiants normalisés du standard, + chacune avec sa `source`. + +## Réalisé — module `05_deliverables_mvp/fiscal/ecf_dgii/` +- `ecf_spec.json` — contrat **structurel** : catalogue des 10 types e-CF DGII + (5 `en_scope` : 31/32/33/34/46), table FormaPago (défaut **3 = Tarjeta** car + encaissements via **Cardnet** · #10), format e-NCF, moneda USD/DOP, ITBIS et + RNC `null`, provider Compupar (endpoints/credentials `null` → VPS #8), 2 + évènements d'émission (réservation, contrat) et `field_map` Dossier Vente → e-CF. +- `ecflib/deps.py` — réutilise (`#5`) `is_filled` / `CANONICAL` / `validate` + (import `sys.path`, zéro duplication, zéro pip). +- `ecflib/ncf.py` — composeur **traçable** de l'e-NCF façon `finance.py` : formule + publiée, `None` si opérande manque (jamais fabriqué), `parse`/`is_valid` qui + rejettent le NCF physique (`B01…`). +- `ecflib/builder.py` — assemblage **déterministe** ; réutilise le `RoleResolver` + du module `workflow_vente` (`role_id → erpnext_role_name`). +- `ecf.schema.json` — contrat de sortie draft-07. +- `ecf_dgii_gen.py` — CLI `build`/`validate`. **Refuse d'écrire** si un des **12 + invariants** de cross-cohérence casse. +- `fixtures/dossier_exemple.json` — fixture **de test uniquement** (opérandes + fictifs + `source` « non contractuel »). +- `out/` (commité, hand-off direct) : `ecf_plan.json`, `MANIFEST.json`. +- `tests/test_ecf_dgii.py` — **39 tests `unittest` (stdlib pur)**. +- **CI** : job `fiscal-ecf-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`). + +## Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC (le cœur du livrable) +Les 12 invariants dérivent l'e-CF des contrats voisins (anti-dérive) : +- `update_value` ∈ workflow **et** état **soumis** (`doc_status = 1`) : jamais + d'e-CF sur un brouillon (lead/visite/devis). +- `base_field` = champ **Currency réel** du DocType Dossier Vente. +- `role_id` résolu depuis `rbac_50_roles.json` **et** portail `compta`. +- `tipo_ecf` = `null` (à confirmer) **ou** code DGII **en périmètre**. +- `moneda` = `devise` (Select USD/DOP · #10) alignée sur le DocType ; TipoCambio + `null` ou sourcé ; FormaPago défaut ∈ table DGII **et** référence Cardnet (#10). +- **Anti-invention (#6)** : RNC / ITBIS jamais sans `source` (`valeurs_a_confirmer + = 6` : emisor + provider + moneda + ITBIS + 2 évènements). + +## Vérifications effectuées (en-repo, sans toucher au VPS) +- **39/39 tests verts** (validateur maison + oracle `jsonschema`) ; génération : + 10 types e-CF (5 en périmètre) · 2 évènements · 6 valeurs à confirmer ; `out/` + commité == régénération bit-à-bit (déterministe). +- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`, + `check_docs.sh` (score présent, 0 lien cassé), YAML `ci.yml` valide. +- **Régression** : 23 Publiciste + 10 RBAC + 16 Faisabilité + 11 fixtures + 12 + userperm + 11 roleprofile + 16 apply_plan + 22 bancable + 25 workflow + 31 + dossier + 25 commissions + 39 e-CF = **241 tests verts** au total. + +## Non fait (hors périmètre worker · VPS · #8) +- Confirmation par la Compta/Direction du `rnc_emisor` + `razon_social` (par + entité émettrice), du taux **ITBIS** (et exonération CONFOTUR éventuelle) et du + **TipoCambio** du jour pour les e-CF USD — chacun **avec source**. +- Configuration du proveedor **Compupar** (endpoints, certificat digital DGII, + credentials `/etc/oto/credentials`) + assignation de la `secuencia` depuis le + rango e-NCF autorisé + câblage sur les transitions du Workflow → agent ERPNext + Backend. + +## Prochaine tâche suggérée +- Sprint 4 Frontend Console : squelette des **5 portails rôle** + (Ventes/Construction/Achat/Compta/Direction) façon clone `/waf-home`. +- Sprint 5 ONAPI/Legal : refactor `oto_module_confotur_application.py` → dépôts + automatiques (P05/P07). + +--- + +**Auto-score 4Big de l'intégration e-CF DGII : 96/100.** Réserve −4 : confirmation +des chiffres fiscaux réels (RNC / ITBIS / TipoCambio) + câblage Compupar en +production = côté VPS (agent ERPNext Backend · #8). Validé statiquement en-repo +(39 tests verts + schéma conforme + 12 invariants de cross-cohérence + gate CI · +241 tests de régression au total). diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/.gitignore b/05_deliverables_mvp/fiscal/ecf_dgii/.gitignore new file mode 100644 index 0000000..36ea299 --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/.gitignore @@ -0,0 +1,4 @@ +# Caches Python +__pycache__/ +*.pyc +# Le dossier out/ EST commite (hand-off ERPNext direct) — voir README. diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/README.md b/05_deliverables_mvp/fiscal/ecf_dgii/README.md new file mode 100644 index 0000000..4d4d03c --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/README.md @@ -0,0 +1,105 @@ +# Intégration e-CF DGII (Compupar) · `OTO e-CF DGII` + +**Sprint 4 · ERPNext Backend** (roadmap ligne 51 : _« e-CF DGII intégration +(Compupar) »_). Produit un **plan de configuration** de la facturation +électronique dominicaine, cross-cohérent avec les contrats CRM déjà livrés : le +pipeline vente [`../../crm/workflow_vente/`](../../crm/workflow_vente/README.md), +le DocType porteur [`../../crm/dossier_vente/`](../../crm/dossier_vente/README.md) +et le contrat RBAC 50 rôles. Il répond à : **quel évènement du pipeline émet un +e-CF, de quel type DGII, sur quel montant, par quel rôle Compta** — et fournit un +**composeur d'e-NCF traçable** `E + tipoeCF(2) + secuencia(10)`. + +> Ce worker **n'écrit jamais sur le VPS** (contrainte #8) : il émet les fichiers +> de hand-off en-repo ; la connexion réelle au proveedor **Compupar** (endpoints, +> certificat digital, credentials) et l'émission en production restent côté agent +> ERPNext Backend. + +## Anti-invention (#6) — pourquoi tout chiffre OTO reste `null` + +Aucun chiffre fiscal propre à OTO n'est documenté dans CLAUDE.md. Fixer ici un +RNC, un taux ITBIS ou un taux de change serait une invention. Donc : + +- `emisor.rnc_emisor`, `taxes[].taux_pct`, `moneda.tipo_cambio` et les endpoints + Compupar portent `null` + `source: null` + `a_confirmer: true`. +- Un invariant **refuse** toute de ces valeurs fixée **sans `source`**. +- Le composeur d'e-NCF reste `None` tant qu'un opérande (tipo / secuencia) manque + — **jamais** fabriqué ; la formule reste affichée (traçabilité façon + [`../../crm/commissions/commlib/finance.py`](../../crm/commissions/commlib/finance.py)). + +Seules les **données de référence DGII** (codes de type e-CF, table FormaPago, +format e-NCF) sont encodées : ce sont des **identifiants normalisés du standard** +e-CF, pas des chiffres OTO — et chacune porte sa `source`. + +## Ce qui est généré (`out/`, commité — hand-off direct) + +| Fichier | Rôle | +|---|---| +| `ecf_plan.json` | Le plan : types e-CF (périmètre), évènements d'émission (rôle Compta résolu + champ de base + tipo), field_map Dossier Vente → e-CF, référence provider Compupar, format e-NCF, FormaPago (défaut Cardnet), moneda USD/DOP. | +| `MANIFEST.json` | Traçabilité (4 sources, comptes, `valeurs_a_confirmer`) + rôles RBAC utilisés + note anti-invention. | + +## Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC (le cœur du livrable) + +Chaque évènement d'émission est **contraint** par les contrats voisins +(anti-dérive · zéro duplication · workflow #5) : + +- **`update_value`** doit exister dans + [`workflow_vente_spec.json`](../../crm/workflow_vente/workflow_vente_spec.json) + **et** correspondre à un état **soumis** (`doc_status = 1`) : on ne facture pas + un brouillon (lead/visite/devis), seulement réservation et contrat. +- **`base_field`** doit être un champ **Currency réel** du DocType Dossier Vente + (`montant_reservation`, `montant_contrat`). +- **`role_id`** doit être résolu depuis + [`rbac_50_roles.json`](../../rbac/rbac_50_roles.json) (via le `RoleResolver` + **réutilisé** du module workflow) **et** appartenir au portail `compta` — le + rôle dédié `compta-fiscaliste-ecf` (_OTO Compta Fiscaliste eCF_). +- **`tipo_ecf`** doit être `null` (à confirmer · jamais fabriqué) **ou** un code + du catalogue DGII marqué `en_scope`. +- **`devise`** (USD/DOP · #10) alimente `TipoMoneda` ; un e-CF USD exige un + `TipoCambio` sourcé. +- **FormaPago défaut = 3 (Tarjeta)** car encaissements OTO via **Cardnet** (#10). + +## Utilisation + +```bash +python3 ecf_dgii_gen.py build # écrit out/ (refuse si invalide) +python3 ecf_dgii_gen.py validate # schéma + 12 invariants, sans écrire +python3 -m unittest discover -s tests -v # 39 tests (stdlib pur) +``` + +## Les 12 invariants (le CLI refuse d'écrire si l'un casse) + +1. Conformité au [schéma de sortie](ecf.schema.json). 2. `update_value` ∈ +workflow vente. 3. État **soumis** uniquement (pas d'e-CF sur brouillon). 4. +`base_field` = champ Currency réel du Dossier Vente. 5. `role_id` du portail +**compta** + `erpnext_role_name` cohérent RBAC. 6. `tipo_ecf` null (à confirmer) +ou code DGII **en périmètre**. 7. Contrat de format **e-NCF** (E+tipo(2)+seq(10)=13, +composition/parse traçables, aucun e-NCF sans sequence). 8. FormaPago défaut ∈ +table DGII + référence **Cardnet** (#10). 9. `moneda` = `devise` USD/DOP alignée +sur le DocType ; TipoCambio null ou sourcé. 10. Anti-invention : RNC / ITBIS +jamais sans `source`. 11. `field_map` : chaque `dossier_field` réel. 12. Unicité +des évènements + comptes du manifeste + `tipos_en_scope` aligné sur le catalogue. + +## Composition traçable de l'e-NCF (`ecflib/ncf.py`) + +`compose_encf(tipo, secuencia)` → `e_ncf = "E" + tipo(2) + secuencia(10)`, avec la +**formule publiée**, `None` si un opérande manque (jamais 0-inventé · #6) et +`champs_manquants`. `parse_encf` / `is_valid_encf` rejettent le NCF physique +(`B01…`). La [fixture](fixtures/dossier_exemple.json) sert **uniquement aux +tests** : ses opérandes sont fictifs et portent une `source` « non contractuel ». + +## Hand-off VPS (agent ERPNext Backend · hors périmètre worker · #8) + +1. La Compta / Direction confirme `rnc_emisor` + `razon_social` (par entité + émettrice), le taux **ITBIS** applicable (et exonération CONFOTUR éventuelle), + le **TipoCambio** du jour pour les e-CF USD — chacun **avec source**. +2. Configurer le proveedor **Compupar** (endpoints, certificat digital DGII, + credentials dans `/etc/oto/credentials` · jamais en repo) et brancher + l'émission sur les transitions du Workflow (réservation / contrat) avec + assignation de la `secuencia` depuis le rango e-NCF autorisé DGII. + +--- + +**Auto-score 4Big : 96/100.** Réserve −4 : confirmation des chiffres fiscaux +réels (RNC / ITBIS / TipoCambio) + câblage Compupar en production côté VPS (agent +ERPNext Backend · #8) ; ce module valide statiquement en-repo (39 tests verts + +schéma + 12 invariants de cross-cohérence + gate CI). diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/ecf.schema.json b/05_deliverables_mvp/fiscal/ecf_dgii/ecf.schema.json new file mode 100644 index 0000000..25b2619 --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/ecf.schema.json @@ -0,0 +1,222 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OTO e-CF DGII (Compupar) · plan de configuration de sortie", + "type": "object", + "required": ["manifest", "ecf_plan"], + "additionalProperties": false, + "properties": { + "manifest": { + "type": "object", + "required": [ + "generated_from", "rbac_source", "workflow_source", "doctype_source", + "source_version", "config_name", "provider", "counts", + "roles_rbac_utilises", "note_anti_invention" + ], + "additionalProperties": false, + "properties": { + "generated_from": { "type": "string" }, + "rbac_source": { "type": "string" }, + "workflow_source": { "type": "string" }, + "doctype_source": { "type": "string" }, + "source_version": { "type": "string" }, + "config_name": { "type": "string" }, + "provider": { "type": "string" }, + "counts": { + "type": "object", + "required": [ + "tipos_ecf", "tipos_en_scope", "formas_pago", "emission_events", + "roles", "valeurs_a_confirmer" + ], + "additionalProperties": false, + "properties": { + "tipos_ecf": { "type": "integer" }, + "tipos_en_scope": { "type": "integer" }, + "formas_pago": { "type": "integer" }, + "emission_events": { "type": "integer" }, + "roles": { "type": "integer" }, + "valeurs_a_confirmer": { "type": "integer" } + } + }, + "roles_rbac_utilises": { + "type": "array", + "items": { + "type": "object", + "required": ["role_id", "erpnext_role_name"], + "additionalProperties": false, + "properties": { + "role_id": { "type": "string" }, + "erpnext_role_name": { "type": "string" } + } + } + }, + "note_anti_invention": { "type": "string" } + } + }, + "ecf_plan": { + "type": "object", + "required": [ + "name", "provider", "devise_field", "emisor", "provider_config", + "e_ncf", "tipos_ecf", "tipos_en_scope", "formas_pago", + "forma_pago_defaut", "moneda", "taxes", "emission_events", "field_map" + ], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "provider": { "type": "string" }, + "devise_field": { "type": "string" }, + "emisor": { + "type": "object", + "required": ["rnc_emisor", "razon_social", "source", "a_confirmer", "note"], + "additionalProperties": false, + "properties": { + "rnc_emisor": { "type": ["string", "null"] }, + "razon_social": { "type": ["string", "null"] }, + "source": { "type": ["string", "null"] }, + "a_confirmer": { "type": "boolean" }, + "note": { "type": "string" } + } + }, + "provider_config": { + "type": "object", + "required": [ + "nom", "role", "endpoint_recepcion", "endpoint_aprobacion_comercial", + "credentials_ref", "certificat_ref", "source", "a_confirmer" + ], + "additionalProperties": false, + "properties": { + "nom": { "type": "string" }, + "role": { "type": "string" }, + "endpoint_recepcion": { "type": ["string", "null"] }, + "endpoint_aprobacion_comercial": { "type": ["string", "null"] }, + "credentials_ref": { "type": "string" }, + "certificat_ref": { "type": "string" }, + "source": { "type": ["string", "null"] }, + "a_confirmer": { "type": "boolean" } + } + }, + "e_ncf": { + "type": "object", + "required": ["prefix", "longueur", "regex", "structure", "note_secuencia", "source"], + "additionalProperties": false, + "properties": { + "prefix": { "type": "string" }, + "longueur": { "type": "integer" }, + "regex": { "type": "string" }, + "structure": { "type": "string" }, + "note_secuencia": { "type": "string" }, + "source": { "type": "string" } + } + }, + "tipos_ecf": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "label", "en_scope"], + "additionalProperties": false, + "properties": { + "code": { "type": "string", "pattern": "^[0-9]{2}$" }, + "label": { "type": "string" }, + "en_scope": { "type": "boolean" } + } + } + }, + "tipos_en_scope": { + "type": "array", + "items": { "type": "string", "pattern": "^[0-9]{2}$" } + }, + "formas_pago": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "label"], + "additionalProperties": false, + "properties": { + "code": { "type": "string" }, + "label": { "type": "string" } + } + } + }, + "forma_pago_defaut": { + "type": "object", + "required": ["code", "motif", "source"], + "additionalProperties": false, + "properties": { + "code": { "type": "string" }, + "motif": { "type": "string" }, + "source": { "type": "string" } + } + }, + "moneda": { + "type": "object", + "required": ["tipo_moneda_field", "options", "tipo_cambio", "note_cambio", "source", "a_confirmer"], + "additionalProperties": false, + "properties": { + "tipo_moneda_field": { "type": "string" }, + "options": { + "type": "array", + "items": { "type": "string" } + }, + "tipo_cambio": { "type": ["number", "string", "null"] }, + "note_cambio": { "type": "string" }, + "source": { "type": ["string", "null"] }, + "a_confirmer": { "type": "boolean" } + } + }, + "taxes": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "label", "taux_pct", "source", "a_confirmer", "note"], + "additionalProperties": false, + "properties": { + "code": { "type": "string" }, + "label": { "type": "string" }, + "taux_pct": { "type": ["number", "string", "null"] }, + "source": { "type": ["string", "null"] }, + "a_confirmer": { "type": "boolean" }, + "note": { "type": "string" } + } + } + }, + "emission_events": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "update_value", "tipo_ecf", "base_field", "role_id", + "erpnext_role_name", "libelle", "a_confirmer" + ], + "additionalProperties": false, + "properties": { + "update_value": { "type": "string" }, + "tipo_ecf": { "type": ["string", "null"], "pattern": "^[0-9]{2}$" }, + "base_field": { "type": "string" }, + "role_id": { "type": "string" }, + "erpnext_role_name": { "type": "string" }, + "libelle": { "type": "string" }, + "a_confirmer": { "type": "boolean" } + } + } + }, + "field_map": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["dossier_field", "ecf_element", "note"], + "additionalProperties": false, + "properties": { + "dossier_field": { "type": "string" }, + "ecf_element": { "type": "string" }, + "note": { "type": "string" } + } + } + } + } + } + } +} diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/ecf_dgii_gen.py b/05_deliverables_mvp/fiscal/ecf_dgii/ecf_dgii_gen.py new file mode 100644 index 0000000..3f4101f --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/ecf_dgii_gen.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Generateur de configuration e-CF DGII (Compupar) · Sprint 4 · ERPNext Backend. + +Roadmap ligne 51 : « e-CF DGII integration (Compupar) ». Produit un plan de +configuration de facturation electronique dominicaine cross-coherent avec les +contrats CRM deja livres : + - le pipeline vente (`workflow_vente_spec.json`) → quel evenement emet ; + - le DocType porteur (`dossier_vente/doctype_spec.json`) → sur quel champ ; + - le contrat RBAC (`rbac_50_roles.json`) → quel role Compta emet. + +Ce worker n'ecrit JAMAIS sur le VPS (#8) : il emet les fichiers de hand-off ; +la connexion reelle au proveedor Compupar (endpoints, certificat, credentials) +et l'emission en production restent cote agent ERPNext Backend. + +ANTI-INVENTION (#6) : aucun chiffre propre a OTO n'est fabrique. RNC emetteur, +taux ITBIS, TipoCambio USD→DOP et endpoints/credentials Compupar restent `null` +(a_confirmer) tant que la Compta ne les a pas confirmes AVEC source ; un invariant +refuse toute valeur fixee sans `source`. Seules les donnees de reference DGII +(codes de type e-CF, formes de paiement, format e-NCF) sont encodees, avec source +— ce sont des identifiants normalises du standard, pas des chiffres OTO. + +Sous-commandes : + build [-o OUT] → ecrit ecf_plan.json + MANIFEST.json + validate → (re)genere en memoire, valide schema + 12 invariants de + cross-coherence e-CF↔workflow↔DocType↔RBAC ; sort en + erreur sinon. + +Sortie deterministe (tri stable, aucun horodatage) → diffable + re-generable. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..")) # 05_deliverables_mvp/ +_CRM = os.path.join(_DELIVERABLES, "crm") + +sys.path.insert(0, _HERE) +sys.path.insert(0, _CRM) +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from ecflib import builder, ncf # noqa: E402 +from workflow_vente.wflib.rbac import RoleResolver # noqa: E402 +from lib import validator as maison # type: ignore # noqa: E402 + +_SPEC_PATH = os.path.join(_HERE, "ecf_spec.json") +_WF_SPEC_PATH = os.path.join(_CRM, "workflow_vente", "workflow_vente_spec.json") +_DT_SPEC_PATH = os.path.join(_CRM, "dossier_vente", "doctype_spec.json") +_SCHEMA_PATH = os.path.join(_HERE, "ecf.schema.json") +_DEFAULT_OUT = os.path.join(_HERE, "out") + + +def _eprint(*args) -> None: + print(*args, file=sys.stderr) + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def _write_json(path: str, data) -> None: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + fh.write("\n") + + +def _currency_fields(dt_spec: dict) -> set[str]: + """Champs Currency du DocType Dossier Vente (bases d'emission legitimes).""" + out: set[str] = set() + for grp in dt_spec.get("field_groups", []): + for f in grp.get("fields", []): + if f.get("fieldtype") == "Currency": + out.add(f["fieldname"]) + return out + + +def _all_fields(dt_spec: dict) -> set[str]: + """Tous les fieldnames du DocType Dossier Vente.""" + out: set[str] = set() + for grp in dt_spec.get("field_groups", []): + for f in grp.get("fields", []): + out.add(f["fieldname"]) + return out + + +def _devise_field(dt_spec: dict) -> dict | None: + for grp in dt_spec.get("field_groups", []): + for f in grp.get("fields", []): + if f["fieldname"] == "devise": + return f + return None + + +def _build() -> tuple[dict, dict, dict, dict, RoleResolver]: + spec = _load(_SPEC_PATH) + wf_spec = _load(_WF_SPEC_PATH) + dt_spec = _load(_DT_SPEC_PATH) + resolver = RoleResolver.from_path() + bundle = builder.build_bundle(spec, resolver) + return bundle, spec, wf_spec, dt_spec, resolver + + +def _validate(bundle: dict, spec: dict, wf_spec: dict, dt_spec: dict, + resolver: RoleResolver) -> list[str]: + """Schema de sortie + 12 invariants de cross-coherence (les 4 contrats).""" + schema = _load(_SCHEMA_PATH) + errors = list(maison.validate(bundle, schema)) + + plan = bundle["ecf_plan"] + m = bundle["manifest"] + events = plan["emission_events"] + + # Contexte derive des contrats voisins. + wf_update_values = {s["update_value"] for s in wf_spec["states"]} + submitted_values = {s["update_value"] for s in wf_spec["states"] + if s["doc_status"] == "1"} + currency_fields = _currency_fields(dt_spec) + all_fields = _all_fields(dt_spec) + tipo_codes = {t["code"] for t in plan["tipos_ecf"]} + en_scope = set(plan["tipos_en_scope"]) + forma_codes = {f["code"] for f in plan["formas_pago"]} + + seen: set[tuple] = set() + for ev in events: + tag = f"{ev['update_value']}/{ev['tipo_ecf']}" + + # 2 · update_value existe dans le workflow vente (anti-derive). + if ev["update_value"] not in wf_update_values: + errors.append(f"[{tag}] update_value absent du workflow vente") + # 3 · emission uniquement sur un etat SOUMIS (doc_status=1) — jamais sur + # un brouillon (lead/visite/devis/abandonne). + elif ev["update_value"] not in submitted_values: + errors.append(f"[{tag}] update_value n'est pas un etat soumis " + f"(doc_status≠1) — pas d'e-CF sur brouillon") + # 4 · base_field est un champ Currency reel du DocType Dossier Vente. + if ev["base_field"] not in currency_fields: + errors.append(f"[{tag}] base_field {ev['base_field']!r} n'est pas un " + f"champ Currency du DocType Dossier Vente") + # 5 · role resolu + portail compta (emission fiscale = concern Compta). + if resolver.portail(ev["role_id"]) != "compta": + errors.append(f"[{tag}] role_id hors portail compta " + f"({resolver.portail(ev['role_id'])!r})") + if ev["erpnext_role_name"] != resolver.erpnext_name(ev["role_id"]): + errors.append(f"[{tag}] erpnext_role_name incoherent avec RBAC") + # 6 · tipo_ecf : soit null + a_confirmer (jamais fabrique · #6), soit un + # code du catalogue ET en_scope. + if ev["tipo_ecf"] is None: + if not ev["a_confirmer"]: + errors.append(f"[{tag}] tipo_ecf null mais a_confirmer=false") + else: + if ev["tipo_ecf"] not in tipo_codes: + errors.append(f"[{tag}] tipo_ecf {ev['tipo_ecf']!r} absent du catalogue DGII") + elif ev["tipo_ecf"] not in en_scope: + errors.append(f"[{tag}] tipo_ecf {ev['tipo_ecf']!r} hors perimetre (en_scope=false)") + # 12a · unicite (update_value, tipo_ecf, role_id). + key = (ev["update_value"], ev["tipo_ecf"], ev["role_id"]) + if key in seen: + errors.append(f"[{tag}] evenement duplique (update_value, tipo_ecf, role_id)") + seen.add(key) + + # 7 · contrat de format e-NCF (structure DGII E+tipo(2)+seq(10) = 13 car.). + encf = plan["e_ncf"] + if encf["prefix"] != "E": + errors.append("e_ncf.prefix doit etre 'E'") + if encf["longueur"] != 13: + errors.append("e_ncf.longueur doit etre 13") + if any(len(c) != 2 or not c.isdigit() for c in tipo_codes): + errors.append("tous les tipos_ecf.code doivent etre 2 chiffres") + # Composition tracable : un e-NCF echantillon (1er type en_scope + sequence + # reelle fournie) est valide et se re-parse sur le meme type. + if en_scope: + sample_tipo = sorted(en_scope)[0] + composed = ncf.compose_encf(sample_tipo, "1") + if composed["e_ncf"] is None or not ncf.is_valid_encf(composed["e_ncf"]): + errors.append("composition e-NCF echantillon invalide (ncf.compose_encf)") + else: + parsed = ncf.parse_encf(composed["e_ncf"]) + if not parsed or parsed["tipo"] != sample_tipo: + errors.append("parse_encf ne retrouve pas le tipo de l'echantillon") + # Anti-invention : sans sequence, aucun e-NCF n'est fabrique. + if ncf.compose_encf(sample_tipo, None)["e_ncf"] is not None: + errors.append("compose_encf fabrique un e-NCF sans sequence (interdit #6)") + + # 8 · forma_pago_defaut ∈ table DGII + reference Cardnet (#10). + fpd = plan["forma_pago_defaut"] + if fpd["code"] not in forma_codes: + errors.append(f"forma_pago_defaut.code {fpd['code']!r} absent de la table FormaPago DGII") + if "cardnet" not in fpd["source"].lower(): + errors.append("forma_pago_defaut.source doit referencer Cardnet (#10)") + + # 9 · moneda : champ devise + options USD/DOP alignees sur le DocType (#10) ; + # TipoCambio null OU source (anti-invention FX). + mon = plan["moneda"] + devf = _devise_field(dt_spec) + if mon["tipo_moneda_field"] != "devise": + errors.append("moneda.tipo_moneda_field doit etre 'devise'") + if mon["options"] != ["USD", "DOP"]: + errors.append("moneda.options ≠ USD/DOP (#10)") + if devf is None: + errors.append("champ `devise` absent du DocType Dossier Vente") + elif [ln for ln in devf.get("options", "").split("\n") if ln] != ["USD", "DOP"]: + errors.append("options du champ `devise` du DocType ≠ USD/DOP (#10)") + if mon["tipo_cambio"] is not None and not mon["source"]: + errors.append("moneda.tipo_cambio fixe sans source (interdit #6)") + + # 10 · ANTI-INVENTION (#6) : RNC emetteur + taux ITBIS jamais sans source. + em = plan["emisor"] + if (em["rnc_emisor"] is not None or em["razon_social"] is not None) and not em["source"]: + errors.append("emisor renseigne sans source (interdit #6)") + for tax in plan["taxes"]: + if tax["taux_pct"] is not None and not tax["source"]: + errors.append(f"tax {tax['code']!r} : taux_pct fixe sans source (interdit #6)") + + # 11 · field_map : chaque dossier_field est un champ reel du Dossier Vente. + for fm in plan["field_map"]: + if fm["dossier_field"] not in all_fields: + errors.append(f"field_map : {fm['dossier_field']!r} n'est pas un champ du Dossier Vente") + + # 12b · comptes du manifeste coherents + tipos_en_scope aligne sur le catalogue. + if m["counts"]["tipos_ecf"] != len(plan["tipos_ecf"]): + errors.append("counts.tipos_ecf incoherent") + if m["counts"]["tipos_en_scope"] != len(en_scope): + errors.append("counts.tipos_en_scope incoherent") + if m["counts"]["formas_pago"] != len(plan["formas_pago"]): + errors.append("counts.formas_pago incoherent") + if m["counts"]["emission_events"] != len(events): + errors.append("counts.emission_events incoherent") + if m["counts"]["roles"] != len({e["role_id"] for e in events}): + errors.append("counts.roles incoherent") + catalogue_en_scope = {t["code"] for t in plan["tipos_ecf"] if t["en_scope"]} + if en_scope != catalogue_en_scope: + errors.append("tipos_en_scope ≠ codes marques en_scope dans le catalogue") + + return errors + + +def cmd_build(args: argparse.Namespace) -> int: + bundle, spec, wf_spec, dt_spec, resolver = _build() + errors = _validate(bundle, spec, wf_spec, dt_spec, resolver) + if errors: + _eprint("❌ Bundle invalide — generation refusee (anti-regression) :") + for e in errors: + _eprint(f" - {e}") + return 1 + + out = os.path.abspath(args.out) + os.makedirs(out, exist_ok=True) + _write_json(os.path.join(out, "ecf_plan.json"), bundle["ecf_plan"]) + _write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"]) + + m = bundle["manifest"] + print(f"✅ Plan e-CF DGII genere dans {out}") + print(f" ecf_plan.json : {m['counts']['tipos_ecf']} types e-CF " + f"({m['counts']['tipos_en_scope']} en perimetre) · " + f"{m['counts']['emission_events']} evenements d'emission · " + f"{m['counts']['valeurs_a_confirmer']} valeurs a confirmer") + print(" ⚠ RNC / ITBIS / TipoCambio / endpoints Compupar cote ERPNext Backend " + "(Compta renseigne avec source · VPS · #8).") + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + bundle, spec, wf_spec, dt_spec, resolver = _build() + errors = _validate(bundle, spec, wf_spec, dt_spec, resolver) + if errors: + _eprint("❌ Validation KO :") + for e in errors: + _eprint(f" - {e}") + return 1 + m = bundle["manifest"] + print(f"✅ Validation OK — config {m['config_name']!r} (provider {m['provider']!r}) : " + f"{m['counts']['emission_events']} evenements, schema + 12 invariants verts.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Generateur de configuration e-CF DGII (Compupar).") + sub = p.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build", help="genere ecf_plan.json / MANIFEST.json") + pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (defaut: ./out)") + pb.set_defaults(func=cmd_build) + + pv = sub.add_parser("validate", help="valide le bundle (schema + 12 invariants) sans ecrire") + pv.set_defaults(func=cmd_validate) + + args = p.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/ecf_spec.json b/05_deliverables_mvp/fiscal/ecf_dgii/ecf_spec.json new file mode 100644 index 0000000..e1ec66d --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/ecf_spec.json @@ -0,0 +1,103 @@ +{ + "version": "1.0.0", + "config_name": "OTO e-CF DGII (Compupar)", + "provider": "Compupar", + "devise_field": "devise", + "_comment": "Contrat STRUCTUREL de l'intégration e-CF DGII via le proveedor certifié Compupar (roadmap Sprint 4 · ERPNext Backend « e-CF DGII intégration (Compupar) »). Il décrit : quels TYPES d'e-CF sont dans le périmètre d'émission OTO, QUEL évènement du pipeline vente déclenche une émission, SUR quel champ de base, PAR quel rôle Compta, et COMMENT les champs du Dossier Vente se projettent sur l'e-CF (field_map). ANTI-INVENTION (#6) : ce fichier n'encode AUCUN chiffre propre à OTO — RNC émetteur, taux ITBIS, TipoCambio USD→DOP et endpoints/credentials Compupar restent `null` + `a_confirmer: true` (le VPS/Direction les renseignent AVEC source · #8). Seules les DONNÉES DE RÉFÉRENCE DGII (codes de type e-CF, formes de paiement, format e-NCF) sont encodées, car ce sont des identifiants normalisés du standard e-CF DGII (pas des chiffres OTO), et elles portent leur `source`. Cross-cohérence : chaque `update_value` référence un état SOUMIS (doc_status=1) du workflow vente ; `base_field` référence un champ Currency du DocType OTO Dossier Vente ; `role_id` référence un rôle du portail `compta` de rbac_50_roles.json. Le worker n'écrit jamais sur le VPS (#8).", + "emisor": { + "rnc_emisor": null, + "razon_social": null, + "source": null, + "a_confirmer": true, + "note": "RNC + raison sociale de l'entité émettrice (WA SRL / AC Arias Cuevas selon projet) — jamais fabriqués ici ; renseignés par la Direction/Compta avec source (#6)." + }, + "provider_config": { + "nom": "Compupar", + "role": "PAC — signe et transmet l'e-CF a la DGII pour le compte de l'emetteur ; recoit l'aprobacion comercial.", + "endpoint_recepcion": null, + "endpoint_aprobacion_comercial": null, + "credentials_ref": "/etc/oto/credentials (VPS · #8 · JAMAIS en repo)", + "certificat_ref": "certificat digital DGII cote VPS (#8)", + "source": "DGII · facturacion electronica — proveedor certifie (Compupar)", + "a_confirmer": true + }, + "e_ncf": { + "prefix": "E", + "longueur": 13, + "regex": "^E[0-9]{12}$", + "structure": "E + tipoeCF (2 chiffres) + secuencia (10 chiffres)", + "note_secuencia": "La secuencia provient d'un RANGO autorise par la DGII (autorizacion de e-NCF) — jamais fabriquee en repo ; assignee cote VPS/Compupar (#8).", + "source": "DGII · formato e-NCF (Ley 32-23 de Facturacion Electronica)" + }, + "tipos_ecf": [ + { "code": "31", "label": "Factura de Credito Fiscal Electronica", "en_scope": true }, + { "code": "32", "label": "Factura de Consumo Electronica", "en_scope": true }, + { "code": "33", "label": "Nota de Debito Electronica", "en_scope": true }, + { "code": "34", "label": "Nota de Credito Electronica", "en_scope": true }, + { "code": "41", "label": "Compras Electronico", "en_scope": false }, + { "code": "43", "label": "Gastos Menores Electronico", "en_scope": false }, + { "code": "44", "label": "Regimenes Especiales Electronico", "en_scope": false }, + { "code": "45", "label": "Gubernamental Electronico", "en_scope": false }, + { "code": "46", "label": "Comprobante para Exportaciones Electronico", "en_scope": true }, + { "code": "47", "label": "Comprobante para Pagos al Exterior Electronico", "en_scope": false } + ], + "formas_pago": [ + { "code": "1", "label": "Efectivo" }, + { "code": "2", "label": "Cheque/Transferencia/Deposito" }, + { "code": "3", "label": "Tarjeta Debito/Credito" }, + { "code": "4", "label": "Venta a Credito" }, + { "code": "5", "label": "Bonos o Certificados de regalo" }, + { "code": "6", "label": "Permuta" }, + { "code": "7", "label": "Nota de credito" }, + { "code": "8", "label": "Otras Formas de pago" } + ], + "forma_pago_defaut": { + "code": "3", + "motif": "Encaissements OTO par carte via Cardnet (CLAUDE.md #10) → FormaPago 3 (Tarjeta).", + "source": "DGII · tabla FormaPago (code 3) · CLAUDE.md #10 (Cardnet)" + }, + "moneda": { + "tipo_moneda_field": "devise", + "options": ["USD", "DOP"], + "tipo_cambio": null, + "note_cambio": "Un e-CF libelle en USD exige un TipoCambio (DOP/USD) SOURCE (taux du jour BCRD/DGII) — jamais fabrique (#6). En DOP, TipoCambio non requis.", + "source": null, + "a_confirmer": true + }, + "taxes": [ + { + "code": "ITBIS", + "label": "Impuesto sobre Transferencias de Bienes Industrializados y Servicios", + "taux_pct": null, + "source": null, + "a_confirmer": true, + "note": "Le taux ITBIS applicable (et l'exoneration eventuelle CONFOTUR sur l'immobilier touristique) est fiscal et depend du projet ; renseigne par le Fiscaliste eCF avec source (#6) — jamais fabrique ici." + } + ], + "emission_events": [ + { + "update_value": "reservation", + "tipo_ecf": null, + "base_field": "montant_reservation", + "role_id": "compta-fiscaliste-ecf", + "libelle": "Emission e-CF sur depot de reservation encaisse", + "a_confirmer": true + }, + { + "update_value": "contrat", + "tipo_ecf": null, + "base_field": "montant_contrat", + "role_id": "compta-fiscaliste-ecf", + "libelle": "Emission e-CF sur contrat signe (comprobante principal)", + "a_confirmer": true + } + ], + "field_map": [ + { "dossier_field": "client", "ecf_element": "Comprador.RazonSocialComprador", "note": "Customer ERPNext → razon social acheteur" }, + { "dossier_field": "devise", "ecf_element": "Encabezado.TipoMoneda", "note": "Select USD/DOP → TipoMoneda (#10)" }, + { "dossier_field": "montant_contrat", "ecf_element": "Totales.MontoTotal", "note": "base Currency contrat → MontoTotal" }, + { "dossier_field": "montant_reservation", "ecf_element": "Totales.MontoTotal", "note": "base Currency reservation → MontoTotal (e-CF de reservation)" }, + { "dossier_field": "projet", "ecf_element": "DetallesItems.Item.NombreItem", "note": "projet/unite → description de la ligne" }, + { "dossier_field": "unite", "ecf_element": "DetallesItems.Item.NombreItem", "note": "reference d'unite → complement description" } + ] +} diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/__init__.py b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/__init__.py new file mode 100644 index 0000000..0f15a9e --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/__init__.py @@ -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. +""" diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/builder.py b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/builder.py new file mode 100644 index 0000000..96770db --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/builder.py @@ -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} diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/deps.py b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/deps.py new file mode 100644 index 0000000..828673b --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/deps.py @@ -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"] diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/ncf.py b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/ncf.py new file mode 100644 index 0000000..7854236 --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/ecflib/ncf.py @@ -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, + } diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/fixtures/dossier_exemple.json b/05_deliverables_mvp/fiscal/ecf_dgii/fixtures/dossier_exemple.json new file mode 100644 index 0000000..2d15e45 --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/fixtures/dossier_exemple.json @@ -0,0 +1,17 @@ +{ + "_comment": "Fixture de TEST uniquement — sert a exercer la composition tracable de l'e-NCF (ecflib/ncf.py) et la projection field_map. Les valeurs ci-dessous sont des EXEMPLES pedagogiques portant une `source` fictive explicite ; elles ne sont PAS committees dans out/ et n'engagent aucun chiffre reel (#6). En production : RNC, ITBIS, TipoCambio et la secuencia e-NCF proviennent de la DGII/Compta/Compupar avec source (#8).", + "dossier": { + "client": "CUST-EXEMPLE-0001", + "projet": "P07 Aqua Terra Las Terrenas", + "unite": "A-1201", + "devise": "USD", + "montant_reservation": 10000, + "montant_contrat": 200000 + }, + "encf_exemple": { + "_comment": "Operandes fictifs de demonstration, avec source explicite — jamais un defaut du contrat livre.", + "tipo": "31", + "secuencia": "34", + "source": "EXEMPLE FICTIF — rango de demonstration test (non contractuel)" + } +} diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/out/MANIFEST.json b/05_deliverables_mvp/fiscal/ecf_dgii/out/MANIFEST.json new file mode 100644 index 0000000..77d61a1 --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/out/MANIFEST.json @@ -0,0 +1,24 @@ +{ + "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": "1.0.0", + "config_name": "OTO e-CF DGII (Compupar)", + "provider": "Compupar", + "counts": { + "tipos_ecf": 10, + "tipos_en_scope": 5, + "formas_pago": 8, + "emission_events": 2, + "roles": 1, + "valeurs_a_confirmer": 6 + }, + "roles_rbac_utilises": [ + { + "role_id": "compta-fiscaliste-ecf", + "erpnext_role_name": "OTO Compta Fiscaliste eCF" + } + ], + "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." +} diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/out/ecf_plan.json b/05_deliverables_mvp/fiscal/ecf_dgii/out/ecf_plan.json new file mode 100644 index 0000000..c1b33b4 --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/out/ecf_plan.json @@ -0,0 +1,201 @@ +{ + "name": "OTO e-CF DGII (Compupar)", + "provider": "Compupar", + "devise_field": "devise", + "emisor": { + "rnc_emisor": null, + "razon_social": null, + "source": null, + "a_confirmer": true, + "note": "RNC + raison sociale de l'entité émettrice (WA SRL / AC Arias Cuevas selon projet) — jamais fabriqués ici ; renseignés par la Direction/Compta avec source (#6)." + }, + "provider_config": { + "nom": "Compupar", + "role": "PAC — signe et transmet l'e-CF a la DGII pour le compte de l'emetteur ; recoit l'aprobacion comercial.", + "endpoint_recepcion": null, + "endpoint_aprobacion_comercial": null, + "credentials_ref": "/etc/oto/credentials (VPS · #8 · JAMAIS en repo)", + "certificat_ref": "certificat digital DGII cote VPS (#8)", + "source": "DGII · facturacion electronica — proveedor certifie (Compupar)", + "a_confirmer": true + }, + "e_ncf": { + "prefix": "E", + "longueur": 13, + "regex": "^E[0-9]{12}$", + "structure": "E + tipoeCF (2 chiffres) + secuencia (10 chiffres)", + "note_secuencia": "La secuencia provient d'un RANGO autorise par la DGII (autorizacion de e-NCF) — jamais fabriquee en repo ; assignee cote VPS/Compupar (#8).", + "source": "DGII · formato e-NCF (Ley 32-23 de Facturacion Electronica)" + }, + "tipos_ecf": [ + { + "code": "31", + "label": "Factura de Credito Fiscal Electronica", + "en_scope": true + }, + { + "code": "32", + "label": "Factura de Consumo Electronica", + "en_scope": true + }, + { + "code": "33", + "label": "Nota de Debito Electronica", + "en_scope": true + }, + { + "code": "34", + "label": "Nota de Credito Electronica", + "en_scope": true + }, + { + "code": "41", + "label": "Compras Electronico", + "en_scope": false + }, + { + "code": "43", + "label": "Gastos Menores Electronico", + "en_scope": false + }, + { + "code": "44", + "label": "Regimenes Especiales Electronico", + "en_scope": false + }, + { + "code": "45", + "label": "Gubernamental Electronico", + "en_scope": false + }, + { + "code": "46", + "label": "Comprobante para Exportaciones Electronico", + "en_scope": true + }, + { + "code": "47", + "label": "Comprobante para Pagos al Exterior Electronico", + "en_scope": false + } + ], + "tipos_en_scope": [ + "31", + "32", + "33", + "34", + "46" + ], + "formas_pago": [ + { + "code": "1", + "label": "Efectivo" + }, + { + "code": "2", + "label": "Cheque/Transferencia/Deposito" + }, + { + "code": "3", + "label": "Tarjeta Debito/Credito" + }, + { + "code": "4", + "label": "Venta a Credito" + }, + { + "code": "5", + "label": "Bonos o Certificados de regalo" + }, + { + "code": "6", + "label": "Permuta" + }, + { + "code": "7", + "label": "Nota de credito" + }, + { + "code": "8", + "label": "Otras Formas de pago" + } + ], + "forma_pago_defaut": { + "code": "3", + "motif": "Encaissements OTO par carte via Cardnet (CLAUDE.md #10) → FormaPago 3 (Tarjeta).", + "source": "DGII · tabla FormaPago (code 3) · CLAUDE.md #10 (Cardnet)" + }, + "moneda": { + "tipo_moneda_field": "devise", + "options": [ + "USD", + "DOP" + ], + "tipo_cambio": null, + "note_cambio": "Un e-CF libelle en USD exige un TipoCambio (DOP/USD) SOURCE (taux du jour BCRD/DGII) — jamais fabrique (#6). En DOP, TipoCambio non requis.", + "source": null, + "a_confirmer": true + }, + "taxes": [ + { + "code": "ITBIS", + "label": "Impuesto sobre Transferencias de Bienes Industrializados y Servicios", + "taux_pct": null, + "source": null, + "a_confirmer": true, + "note": "Le taux ITBIS applicable (et l'exoneration eventuelle CONFOTUR sur l'immobilier touristique) est fiscal et depend du projet ; renseigne par le Fiscaliste eCF avec source (#6) — jamais fabrique ici." + } + ], + "emission_events": [ + { + "update_value": "contrat", + "tipo_ecf": null, + "base_field": "montant_contrat", + "role_id": "compta-fiscaliste-ecf", + "erpnext_role_name": "OTO Compta Fiscaliste eCF", + "libelle": "Emission e-CF sur contrat signe (comprobante principal)", + "a_confirmer": true + }, + { + "update_value": "reservation", + "tipo_ecf": null, + "base_field": "montant_reservation", + "role_id": "compta-fiscaliste-ecf", + "erpnext_role_name": "OTO Compta Fiscaliste eCF", + "libelle": "Emission e-CF sur depot de reservation encaisse", + "a_confirmer": true + } + ], + "field_map": [ + { + "dossier_field": "client", + "ecf_element": "Comprador.RazonSocialComprador", + "note": "Customer ERPNext → razon social acheteur" + }, + { + "dossier_field": "devise", + "ecf_element": "Encabezado.TipoMoneda", + "note": "Select USD/DOP → TipoMoneda (#10)" + }, + { + "dossier_field": "montant_contrat", + "ecf_element": "Totales.MontoTotal", + "note": "base Currency contrat → MontoTotal" + }, + { + "dossier_field": "montant_reservation", + "ecf_element": "Totales.MontoTotal", + "note": "base Currency reservation → MontoTotal (e-CF de reservation)" + }, + { + "dossier_field": "projet", + "ecf_element": "DetallesItems.Item.NombreItem", + "note": "projet/unite → description de la ligne" + }, + { + "dossier_field": "unite", + "ecf_element": "DetallesItems.Item.NombreItem", + "note": "reference d'unite → complement description" + } + ] +} diff --git a/05_deliverables_mvp/fiscal/ecf_dgii/tests/test_ecf_dgii.py b/05_deliverables_mvp/fiscal/ecf_dgii/tests/test_ecf_dgii.py new file mode 100644 index 0000000..70f61d3 --- /dev/null +++ b/05_deliverables_mvp/fiscal/ecf_dgii/tests/test_ecf_dgii.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Tests du generateur de configuration e-CF DGII (Compupar) · Sprint 4. + +Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea. +La bibliotheque `jsonschema` sert d'*oracle* quand elle est presente, pour se +premunir d'un ecart entre le validateur maison et draft-07. + +Trois axes : + 1. CROSS-COHERENCE e-CF ↔ workflow ↔ DocType ↔ RBAC (les 12 invariants : chaque + emission se declenche sur un etat SOUMIS, sur un champ Currency reel, par un + role Compta resolu, avec un tipo_ecf en perimetre — ou null a confirmer). + 2. Composition TRACABLE de l'e-NCF (ecflib/ncf.py) : E+tipo(2)+seq(10)=13, None + si un operande manque (anti 0-invente · #6), re-parse fidele. + 3. ANTI-INVENTION (#6) : RNC / ITBIS / TipoCambio jamais fixes sans source. +""" + +from __future__ import annotations + +import copy +import json +import os +import subprocess +import sys +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_MODULE = os.path.normpath(os.path.join(_HERE, "..")) +_DELIVERABLES = os.path.normpath(os.path.join(_MODULE, "..", "..")) +_CRM = os.path.join(_DELIVERABLES, "crm") + +sys.path.insert(0, _MODULE) +sys.path.insert(0, _CRM) +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from ecflib import builder, ncf # noqa: E402 +from workflow_vente.wflib.rbac import RoleResolver # noqa: E402 +from lib import validator as maison # type: ignore # noqa: E402 +import ecf_dgii_gen as gen # noqa: E402 + +try: + import jsonschema # type: ignore + + _HAS_JSONSCHEMA = True +except Exception: # pragma: no cover + _HAS_JSONSCHEMA = False + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +class BuildBaseline(unittest.TestCase): + """Le contrat vanille passe schema + 12 invariants et est deterministe.""" + + def setUp(self): + self.bundle, self.spec, self.wf, self.dt, self.resolver = gen._build() + + def test_validate_clean(self): + errors = gen._validate(self.bundle, self.spec, self.wf, self.dt, self.resolver) + self.assertEqual(errors, [], f"invariants casses : {errors}") + + def test_deterministe(self): + b2 = builder.build_bundle(self.spec, self.resolver) + self.assertEqual( + json.dumps(self.bundle, sort_keys=True, ensure_ascii=False), + json.dumps(b2, sort_keys=True, ensure_ascii=False), + ) + + def test_provider_compupar(self): + self.assertEqual(self.bundle["ecf_plan"]["provider"], "Compupar") + self.assertEqual(self.bundle["manifest"]["provider"], "Compupar") + + def test_schema_maison(self): + schema = _load(gen._SCHEMA_PATH) + self.assertEqual(maison.validate(self.bundle, schema), []) + + @unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema non installe (oracle optionnel)") + def test_schema_oracle_jsonschema(self): + schema = _load(gen._SCHEMA_PATH) + jsonschema.validate(self.bundle, schema) # ne doit pas lever + + def test_counts_coherents(self): + m = self.bundle["manifest"]["counts"] + plan = self.bundle["ecf_plan"] + self.assertEqual(m["tipos_ecf"], len(plan["tipos_ecf"])) + self.assertEqual(m["tipos_en_scope"], len(plan["tipos_en_scope"])) + self.assertEqual(m["emission_events"], len(plan["emission_events"])) + + +class CrossCoherence(unittest.TestCase): + """Chaque invariant attrape la mutation correspondante.""" + + def setUp(self): + self.bundle, self.spec, self.wf, self.dt, self.resolver = gen._build() + + def _errs(self, mutate): + b = copy.deepcopy(self.bundle) + mutate(b) + return gen._validate(b, self.spec, self.wf, self.dt, self.resolver) + + def test_update_value_inconnu(self): + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("update_value", "zzz")) + self.assertTrue(any("absent du workflow" in e for e in errs), errs) + + def test_etat_non_soumis_refuse(self): + # 'lead' existe mais doc_status=0 → pas d'e-CF sur brouillon. + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("update_value", "lead")) + self.assertTrue(any("pas d'e-CF sur brouillon" in e for e in errs), errs) + + def test_base_field_non_currency(self): + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("base_field", "projet")) + self.assertTrue(any("Currency" in e for e in errs), errs) + + def test_base_field_inexistant(self): + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("base_field", "inexistant")) + self.assertTrue(any("Currency" in e for e in errs), errs) + + def test_role_hors_compta(self): + # ventes-conseiller existe dans RBAC mais portail ventes, pas compta. + def mut(b): + ev = b["ecf_plan"]["emission_events"][0] + ev["role_id"] = "ventes-conseiller" + ev["erpnext_role_name"] = self.resolver.erpnext_name("ventes-conseiller") + errs = self._errs(mut) + self.assertTrue(any("hors portail compta" in e for e in errs), errs) + + def test_role_name_incoherent(self): + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("erpnext_role_name", "Faux Role")) + self.assertTrue(any("incoherent avec RBAC" in e for e in errs), errs) + + def test_tipo_null_exige_a_confirmer(self): + def mut(b): + ev = b["ecf_plan"]["emission_events"][0] + ev["tipo_ecf"] = None + ev["a_confirmer"] = False + errs = self._errs(mut) + self.assertTrue(any("a_confirmer=false" in e for e in errs), errs) + + def test_tipo_hors_catalogue(self): + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("tipo_ecf", "99")) + self.assertTrue(any("absent du catalogue" in e for e in errs), errs) + + def test_tipo_hors_scope(self): + # 41 est un type reel du catalogue mais en_scope=false (achats). + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("tipo_ecf", "41")) + self.assertTrue(any("hors perimetre" in e for e in errs), errs) + + def test_tipo_en_scope_valide(self): + # 31 est en_scope → aucune erreur specifique de type. + errs = self._errs(lambda b: b["ecf_plan"]["emission_events"][0].__setitem__("tipo_ecf", "31")) + self.assertFalse(any("catalogue" in e or "perimetre" in e for e in errs), errs) + + def test_evenement_duplique(self): + def mut(b): + evs = b["ecf_plan"]["emission_events"] + evs.append(copy.deepcopy(evs[0])) + errs = self._errs(mut) + self.assertTrue(any("duplique" in e for e in errs), errs) + + def test_forma_pago_defaut_hors_table(self): + errs = self._errs(lambda b: b["ecf_plan"]["forma_pago_defaut"].__setitem__("code", "99")) + self.assertTrue(any("FormaPago" in e for e in errs), errs) + + def test_forma_pago_source_sans_cardnet(self): + errs = self._errs(lambda b: b["ecf_plan"]["forma_pago_defaut"].__setitem__("source", "DGII seul")) + self.assertTrue(any("Cardnet" in e for e in errs), errs) + + def test_moneda_options_non_usd_dop(self): + errs = self._errs(lambda b: b["ecf_plan"]["moneda"].__setitem__("options", ["EUR"])) + self.assertTrue(any("USD/DOP" in e for e in errs), errs) + + def test_field_map_champ_inexistant(self): + errs = self._errs(lambda b: b["ecf_plan"]["field_map"][0].__setitem__("dossier_field", "inexistant")) + self.assertTrue(any("n'est pas un champ du Dossier Vente" in e for e in errs), errs) + + def test_tipos_en_scope_desaligne(self): + errs = self._errs(lambda b: b["ecf_plan"]["tipos_en_scope"].append("99")) + self.assertTrue(any("tipos_en_scope" in e for e in errs), errs) + + +class AntiInvention(unittest.TestCase): + """#6 : aucun chiffre OTO fixe sans source.""" + + def setUp(self): + self.bundle, self.spec, self.wf, self.dt, self.resolver = gen._build() + + def _errs(self, mutate): + b = copy.deepcopy(self.bundle) + mutate(b) + return gen._validate(b, self.spec, self.wf, self.dt, self.resolver) + + def test_baseline_tout_a_confirmer(self): + plan = self.bundle["ecf_plan"] + self.assertIsNone(plan["emisor"]["rnc_emisor"]) + self.assertIsNone(plan["moneda"]["tipo_cambio"]) + self.assertTrue(all(t["taux_pct"] is None for t in plan["taxes"])) + self.assertTrue(all(e["tipo_ecf"] is None for e in plan["emission_events"])) + + def test_rnc_sans_source_refuse(self): + errs = self._errs(lambda b: b["ecf_plan"]["emisor"].__setitem__("rnc_emisor", "101010101")) + self.assertTrue(any("emisor renseigne sans source" in e for e in errs), errs) + + def test_rnc_avec_source_ok(self): + def mut(b): + b["ecf_plan"]["emisor"]["rnc_emisor"] = "101010101" + b["ecf_plan"]["emisor"]["source"] = "RNC officiel WA SRL (registre DGII)" + errs = self._errs(mut) + self.assertFalse(any("emisor renseigne sans source" in e for e in errs), errs) + + def test_itbis_sans_source_refuse(self): + errs = self._errs(lambda b: b["ecf_plan"]["taxes"][0].__setitem__("taux_pct", 18)) + self.assertTrue(any("taux_pct fixe sans source" in e for e in errs), errs) + + def test_tipo_cambio_sans_source_refuse(self): + errs = self._errs(lambda b: b["ecf_plan"]["moneda"].__setitem__("tipo_cambio", 60)) + self.assertTrue(any("tipo_cambio fixe sans source" in e for e in errs), errs) + + def test_valeurs_a_confirmer_comptees(self): + # emisor + provider + moneda + 1 tax + 2 events = 6. + self.assertEqual(self.bundle["manifest"]["counts"]["valeurs_a_confirmer"], 6) + + +class NcfTracable(unittest.TestCase): + """Composition e-NCF tracable (E+tipo(2)+seq(10)) · anti 0-invente (#6).""" + + def test_compose_complet(self): + r = ncf.compose_encf("31", "34") + self.assertEqual(r["e_ncf"], "E310000000034") + self.assertEqual(len(r["e_ncf"]), 13) + self.assertFalse(r["incomplete"]) + self.assertTrue(ncf.is_valid_encf(r["e_ncf"])) + + def test_compose_sans_secuencia_reste_none(self): + r = ncf.compose_encf("31", None) + self.assertIsNone(r["e_ncf"]) + self.assertTrue(r["incomplete"]) + self.assertIn("secuencia", r["champs_manquants"]) + + def test_compose_sans_tipo_reste_none(self): + r = ncf.compose_encf(None, "34") + self.assertIsNone(r["e_ncf"]) + self.assertIn("tipo", r["champs_manquants"]) + + def test_formule_toujours_publiee(self): + self.assertEqual(ncf.compose_encf(None, None)["formule"], "E + tipoeCF(2) + secuencia(10)") + + def test_tipo_mal_forme_rejete(self): + self.assertIsNone(ncf.compose_encf("3", "34")["e_ncf"]) # 1 chiffre + self.assertIsNone(ncf.compose_encf("abc", "34")["e_ncf"]) + + def test_secuencia_trop_longue_rejetee(self): + self.assertIsNone(ncf.compose_encf("31", "12345678901")["e_ncf"]) # 11 chiffres + + def test_parse_roundtrip(self): + r = ncf.compose_encf("46", "999") + p = ncf.parse_encf(r["e_ncf"]) + self.assertEqual(p, {"prefix": "E", "tipo": "46", "secuencia": "0000000999"}) + + def test_is_valid_rejette_ncf_physique(self): + # NCF physique (B01...) n'est pas un e-NCF. + self.assertFalse(ncf.is_valid_encf("B0100000001")) + self.assertFalse(ncf.is_valid_encf(None)) + + def test_fixture_exemple(self): + fx = _load(os.path.join(_MODULE, "fixtures", "dossier_exemple.json")) + ex = fx["encf_exemple"] + r = ncf.compose_encf(ex["tipo"], ex["secuencia"]) + self.assertEqual(r["e_ncf"], "E310000000034") + self.assertIn("FICTIF", ex["source"]) + + +class CliSmoke(unittest.TestCase): + """Le CLI build/validate tourne et l'output re-valide (idempotence).""" + + def test_validate_cli(self): + r = subprocess.run([sys.executable, "ecf_dgii_gen.py", "validate"], + cwd=_MODULE, capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("12 invariants verts", r.stdout) + + def test_build_output_revalide(self): + plan = _load(os.path.join(_MODULE, "out", "ecf_plan.json")) + manifest = _load(os.path.join(_MODULE, "out", "MANIFEST.json")) + schema = _load(gen._SCHEMA_PATH) + self.assertEqual(maison.validate({"manifest": manifest, "ecf_plan": plan}, schema), []) + + +if __name__ == "__main__": + unittest.main(verbosity=2)