[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
+22 -1
View File
@@ -237,10 +237,31 @@ jobs:
- name: Tests générateur barème commissions - name: Tests générateur barème commissions
run: python3 -m unittest discover -s tests -v 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: gate:
name: Gate qualité (agrégat) name: Gate qualité (agrégat)
runs-on: ubuntu-latest 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: steps:
- name: Résultat - name: Résultat
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
+45
View File
@@ -1,5 +1,50 @@
# Activity Log · 2026-07-30 · Claude Code DTP # 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) ## Session `20260730_062706` (session 13)
**Tâche** : Sprint 4 · ERPNext Backend — Générateur du **barème de commissions **Tâche** : Sprint 4 · ERPNext Backend — Générateur du **barème de commissions
@@ -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).
@@ -0,0 +1,4 @@
# Caches Python
__pycache__/
*.pyc
# Le dossier out/ EST commite (hand-off ERPNext direct) — voir README.
@@ -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).
@@ -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" }
}
}
}
}
}
}
}
@@ -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())
@@ -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" }
]
}
@@ -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,
}
@@ -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)"
}
}
@@ -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."
}
@@ -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"
}
]
}
@@ -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)