Saltar al contenido principal

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,
)
Protege la API Key

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.