Integración con Odoo
Guía para integrar el cliente Python (ecf_client.py) en un módulo de Odoo, por ejemplo l10n_do_ecf_connect. Compatible con los entornos de ejecución de Odoo 14, 15, 16, 17 y 18.
Dentro de Odoo, FacturoAPIError hereda de odoo.exceptions.UserError: los errores de la API se muestran al usuario como diálogos de advertencia nativos, sin trazas.
Paso 1: Agrega el cliente al módulo
Copia ecf_client.py dentro del directorio tools/ de tu módulo:
l10n_do_ecf_connect/
├── __init__.py
├── __manifest__.py
├── tools/
│ ├── __init__.py
│ └── ecf_client.py <-- aquí
├── models/
│ ├── account_move.py
│ └── res_company.py
└── wizards/
└── cancel_encf_range_wizard.py
Asegúrate de que tools/__init__.py lo importe:
from . import ecf_client
Paso 2: Configuración por compañía (res.company)
Agrega los campos de configuración y un método que construya el cliente a partir de ellos:
# models/res_company.py
from odoo import models, fields
from odoo.exceptions import UserError
from ..tools.ecf_client import FacturoClient
class ResCompany(models.Model):
_inherit = "res.company"
facturo_api_url = fields.Char("Facturo API URL", default="https://ecf.facturord.app/")
facturo_api_key = fields.Char("Facturo API Key")
facturo_company_id = fields.Char("Facturo Company UUID")
facturo_dgii_env = fields.Selection(
[("testecf", "Pruebas (TesteCF)"), ("certecf", "Certificación"), ("ecf", "Producción")],
string="Ambiente DGII",
default="testecf",
)
def get_facturo_client(self):
self.ensure_one()
if not self.facturo_api_key:
raise UserError("La API Key de Facturo no está configurada para la compañía %s." % self.name)
return FacturoClient(
base_url=self.facturo_api_url,
api_key=self.facturo_api_key,
company_id=self.facturo_company_id,
)
Restringe el campo facturo_api_key a grupos administrativos (por ejemplo con groups="base.group_system") para que no sea visible a todos los usuarios.
Paso 3: Anular el e-NCF al cancelar una factura (account.move)
Cuando se cancela en Odoo una factura con e-NCF asignado antes de que la DGII la haya recibido o aceptado, anula la secuencia vía Facturo:
# models/account_move.py
from odoo import models, _
class AccountMove(models.Model):
_inherit = "account.move"
def button_cancel(self):
for move in self:
# Solo si el e-NCF NUNCA fue aceptado por la DGII.
# Si ya fue aceptado, ANECF no aplica: se requiere una Nota de Crédito (E34).
if (
move.is_ecf_invoice
and move.l10n_do_fiscal_number
and move.ecf_status not in ("delivered", "accepted")
):
client = move.company_id.get_facturo_client()
ecf_type = int(move.l10n_latam_document_type_id.code) # ej.: 31, 32
res = client.cancel_single_sequence(
ecf_type=ecf_type,
encf=move.l10n_do_fiscal_number,
dgii_env=move.company_id.facturo_dgii_env,
)
move.message_post(
body=_(
"<b>e-NCF anulado ante la DGII:</b> %s<br/>"
"Código DGII: %s (%s)"
) % (move.l10n_do_fiscal_number, res.get("code"), res.get("name"))
)
return super().button_cancel()
Los campos is_ecf_invoice, l10n_do_fiscal_number y ecf_status corresponden a la localización dominicana de tu instalación; ajústalos a los nombres que use tu módulo.
Paso 4 (opcional): Asistente de anulación por rangos
Para la anulación administrativa de lotes de secuencias saltadas, dañadas o vencidas:
# wizards/cancel_encf_range_wizard.py
from odoo import models, fields, _
class CancelENCFRangeWizard(models.TransientModel):
_name = "ecf.cancel.range.wizard"
_description = "Asistente para Anulación de Rangos de e-NCF en DGII"
company_id = fields.Many2one(
"res.company",
string="Compañía",
required=True,
default=lambda self: self.env.company,
)
ecf_type = fields.Selection([
("31", "31 - Factura de Crédito Fiscal Electrónica"),
("32", "32 - Factura de Consumo Electrónica"),
("33", "33 - Nota de Débito Electrónica"),
("34", "34 - Nota de Crédito Electrónica"),
("41", "41 - Compras Electrónico"),
("43", "43 - Gastos Menores Electrónico"),
("44", "44 - Regímenes Especiales Electrónica"),
("45", "45 - Gubernamental Electrónico"),
("46", "46 - Exportaciones Electrónico"),
("47", "47 - Pagos al Exterior Electrónico"),
], string="Tipo de e-CF", required=True)
from_encf = fields.Char("Desde e-NCF", size=13, required=True)
to_encf = fields.Char("Hasta e-NCF", size=13, required=True)
def action_confirm_cancellation(self):
self.ensure_one()
client = self.company_id.get_facturo_client()
res = client.cancel_sequence_range(
ecf_type=int(self.ecf_type),
from_encf=self.from_encf,
to_encf=self.to_encf,
dgii_env=self.company_id.facturo_dgii_env,
)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": _("Anulación DGII exitosa"),
"message": _("Total de secuencias anuladas: %s (Respuesta DGII: %s)") % (
res.get("total_cancelled"),
res.get("name"),
),
"type": "success",
"sticky": False,
},
}
Reglas ANECF a recordar
Consulta las reglas de negocio de ANECF: solo secuencias no aceptadas, e-NCF de 13 caracteres, series E–Z (excepto P) y rangos en orden ascendente.