Integrate fiscal cancellation reasons

This commit is contained in:
José Arturo García
2026-07-22 21:52:32 -04:00
parent 288aea39a5
commit c1969f254f
5 changed files with 209 additions and 4 deletions
+101 -4
View File
@@ -7,9 +7,9 @@ from sql.aggregate import Sum
from sql.conditionals import Coalesce from sql.conditionals import Coalesce
from sql.functions import Round from sql.functions import Round
from trytond import backend from trytond import backend
from trytond.model import fields from trytond.model import ModelView, Workflow, fields
from trytond.pool import Pool, PoolMeta from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval, If, And from trytond.pyson import Eval, If, And, Bool
from trytond.transaction import Transaction from trytond.transaction import Transaction
from trytond.exceptions import UserError from trytond.exceptions import UserError
from trytond.i18n import gettext from trytond.i18n import gettext
@@ -293,6 +293,43 @@ class Invoice(metaclass=PoolMeta):
isr_withholding_type_string = isr_withholding_type.translated( isr_withholding_type_string = isr_withholding_type.translated(
'isr_withholding_type') 'isr_withholding_type')
ncf_cancellation_type = fields.Selection(
_CANCELLATION_TYPE, 'NCF Cancellation Type',
states={
'required': (
(Eval('state') == 'cancelled')
& (Eval('type') == 'out')
& Bool(Eval('ncf_number'))
& ~Eval('context', {}).get('invoice_force_cancel', False)),
'readonly': Eval('state') == 'cancelled',
'invisible': Eval('type') == 'in',
},
depends=['state', 'type', 'ncf_number'])
ncf_cancellation_type_string = ncf_cancellation_type.translated(
'ncf_cancellation_type')
ncf_cancellation_reason = fields.Text(
'NCF Cancellation Reason',
states={
'required': (
(Eval('state') == 'cancelled')
& (Eval('type') == 'out')
& Bool(Eval('ncf_number'))
& ~Eval('context', {}).get('invoice_force_cancel', False)),
'readonly': Eval('state') == 'cancelled',
'invisible': Eval('type') == 'in',
},
depends=['state', 'type', 'ncf_number'])
@classmethod
def __setup__(cls):
super().__setup__()
cls._check_modify_exclude.update({
'ncf_cancellation_type',
'ncf_cancellation_reason',
})
@classmethod @classmethod
def __register__(cls, module_name): def __register__(cls, module_name):
""" """
@@ -308,6 +345,7 @@ class Invoice(metaclass=PoolMeta):
cls._ncf_prepare_legacy_invoice_ncf_type_column() cls._ncf_prepare_legacy_invoice_ncf_type_column()
super().__register__(module_name) super().__register__(module_name)
cls._ncf_migrate_invoice_ncf_type_from_legacy_column() cls._ncf_migrate_invoice_ncf_type_from_legacy_column()
cls._ncf_migrate_legacy_cancellation_fields()
@classmethod @classmethod
def _ncf_column_exists(cls, cursor, table_name, column_name): def _ncf_column_exists(cls, cursor, table_name, column_name):
@@ -447,6 +485,35 @@ class Invoice(metaclass=PoolMeta):
unmatched, unmatched,
) )
@classmethod
def _ncf_migrate_legacy_cancellation_fields(cls):
"""
Copy data from the former account_invoice_cancel_reason module.
The old module used generic cancellation_type/cancellation_reason
columns. ncf_manager stores those values as fiscal DGII cancellation
data so they can later feed the NCF annulment report.
"""
cursor = Transaction().connection.cursor()
table_name = 'account_invoice'
field_pairs = (
('cancellation_type', 'ncf_cancellation_type'),
('cancellation_reason', 'ncf_cancellation_reason'),
)
for old_column, new_column in field_pairs:
if not cls._ncf_column_exists(cursor, table_name, old_column):
continue
if not cls._ncf_column_exists(cursor, table_name, new_column):
continue
cursor.execute(f"""
UPDATE {table_name}
SET {new_column} = {old_column}
WHERE {old_column} IS NOT NULL
AND {old_column} <> ''
AND ({new_column} IS NULL OR {new_column} = '')
""")
@classmethod @classmethod
def search_rec_name(cls, name, clause): def search_rec_name(cls, name, clause):
domain = super().search_rec_name(name, clause) domain = super().search_rec_name(name, clause)
@@ -1645,6 +1712,25 @@ class Invoice(metaclass=PoolMeta):
raise UserError(gettext( raise UserError(gettext(
'ncf_manager.msg_incorrect_lenght_ncf_number_modified')) 'ncf_manager.msg_incorrect_lenght_ncf_number_modified'))
@staticmethod
def _ncf_is_fiscal_cancellation_required(invoice):
return (
invoice.type == 'out'
and bool(getattr(invoice, 'ncf_number', None)))
@classmethod
def _ncf_check_cancellation_information(cls, invoice):
if not cls._ncf_is_fiscal_cancellation_required(invoice):
return
if not invoice.ncf_cancellation_type:
raise UserError(gettext(
'ncf_manager.msg_missing_ncf_cancellation_type',
invoice=invoice.rec_name))
if not invoice.ncf_cancellation_reason:
raise UserError(gettext(
'ncf_manager.msg_missing_ncf_cancellation_reason',
invoice=invoice.rec_name))
@classmethod @classmethod
def copy(cls, invoices, default=None): def copy(cls, invoices, default=None):
if default is None: if default is None:
@@ -1658,6 +1744,8 @@ class Invoice(metaclass=PoolMeta):
default.setdefault('ncf_number_modified', None) default.setdefault('ncf_number_modified', None)
default.setdefault('expiration_date', None) default.setdefault('expiration_date', None)
default.setdefault('security_code', None) default.setdefault('security_code', None)
default.setdefault('ncf_cancellation_type', None)
default.setdefault('ncf_cancellation_reason', None)
return super(Invoice, cls).copy(invoices, default=default) return super(Invoice, cls).copy(invoices, default=default)
@@ -1674,11 +1762,20 @@ class Invoice(metaclass=PoolMeta):
('/form/notebook/page[@id="tax_information"]/separator[@id="expense"]', 'states', { ('/form/notebook/page[@id="tax_information"]/separator[@id="expense"]', 'states', {
'invisible': Eval('type') == 'out', 'invisible': Eval('type') == 'out',
}), }),
('/form/notebook/page[@id="cancel_reason"]', 'states', { ('/form/notebook/page[@id="ncf_cancellation"]', 'states', {
'invisible': Eval('type') == 'in', 'invisible': (
(Eval('type') == 'in') | ~Bool(Eval('ncf_number'))),
}), }),
] ]
@classmethod
@ModelView.button
@Workflow.transition('cancelled')
def cancel(cls, invoices):
if not Transaction().context.get('invoice_force_cancel'):
for invoice in invoices:
cls._ncf_check_cancellation_information(invoice)
super().cancel(invoices)
@classmethod @classmethod
def _post(cls, invoices): def _post(cls, invoices):
+64
View File
@@ -139,6 +139,14 @@ msgctxt "field:account.invoice,ncf_type_name:"
msgid "NCF Type" msgid "NCF Type"
msgstr "Tipo NCF" msgstr "Tipo NCF"
msgctxt "field:account.invoice,ncf_cancellation_type:"
msgid "NCF Cancellation Type"
msgstr "Tipo de Anulación NCF"
msgctxt "field:account.invoice,ncf_cancellation_reason:"
msgid "NCF Cancellation Reason"
msgstr "Motivo de Anulación NCF"
msgctxt "selection:account.invoice,ncf_type:" msgctxt "selection:account.invoice,ncf_type:"
msgid "Credit Note" msgid "Credit Note"
msgstr "Nota de Crédito" msgstr "Nota de Crédito"
@@ -175,6 +183,46 @@ msgctxt "selection:account.invoice,ncf_type:"
msgid "Valid Invoice for Tax Credit" msgid "Valid Invoice for Tax Credit"
msgstr "Factura Válida para Crédito Fiscal" msgstr "Factura Válida para Crédito Fiscal"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "01 Deterioro de Factura Pre-Impresa"
msgstr "01 Deterioro de Factura Pre-Impresa"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "02 Errores de Impresión (Factura Pre-Impresa)"
msgstr "02 Errores de Impresión (Factura Pre-Impresa)"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "03 Impresión Defectuosa"
msgstr "03 Impresión Defectuosa"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "04 Corrección de la Información"
msgstr "04 Corrección de la Información"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "05 Cambio de Productos"
msgstr "05 Cambio de Productos"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "06 Devolución de Productos"
msgstr "06 Devolución de Productos"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "07 Omisión de Productos"
msgstr "07 Omisión de Productos"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "08 Errores en Secuencias de NCF"
msgstr "08 Errores en Secuencias de NCF"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "09 Por Cese de Operaciones"
msgstr "09 Por Cese de Operaciones"
msgctxt "selection:account.invoice,ncf_cancellation_type:"
msgid "10 Pérdida O Hurto De Talonario(S)"
msgstr "10 Pérdida O Hurto De Talonario(S)"
msgctxt "view:account.fiscalyear:" msgctxt "view:account.fiscalyear:"
msgid "NCF Sequences" msgid "NCF Sequences"
msgstr "Secuencias de NCF" msgstr "Secuencias de NCF"
@@ -183,6 +231,10 @@ msgctxt "view:account.invoice:"
msgid "Tax Information" msgid "Tax Information"
msgstr "Información Fiscal" msgstr "Información Fiscal"
msgctxt "view:account.invoice:"
msgid "NCF Cancellation"
msgstr "Anulación NCF"
msgctxt "view:account.invoice:" msgctxt "view:account.invoice:"
msgid "Revenue" msgid "Revenue"
msgstr "Ingreso" msgstr "Ingreso"
@@ -710,6 +762,18 @@ msgctxt "model:ir.message,text:msg_missing_credit_note_sequence"
msgid "No active credit note sequence available for type \"%(ncf_type)s\" in company \"%(company)s\"." msgid "No active credit note sequence available for type \"%(ncf_type)s\" in company \"%(company)s\"."
msgstr "No hay secuencia de nota de crédito activa para el tipo \"%(ncf_type)s\" en la empresa \"%(company)s\"." msgstr "No hay secuencia de nota de crédito activa para el tipo \"%(ncf_type)s\" en la empresa \"%(company)s\"."
msgctxt "model:ir.message,text:msg_missing_debit_note_sequence"
msgid "No active debit note sequence available for type \"%(ncf_type)s\" in company \"%(company)s\"."
msgstr "No hay secuencia de nota de débito activa para el tipo \"%(ncf_type)s\" en la empresa \"%(company)s\"."
msgctxt "model:ir.message,text:msg_missing_ncf_cancellation_type"
msgid "You must select a DGII cancellation type before cancelling fiscal invoice \"%(invoice)s\"."
msgstr "Debe seleccionar un tipo de anulación DGII antes de cancelar la factura fiscal \"%(invoice)s\"."
msgctxt "model:ir.message,text:msg_missing_ncf_cancellation_reason"
msgid "You must enter a DGII cancellation reason before cancelling fiscal invoice \"%(invoice)s\"."
msgstr "Debe indicar un motivo de anulación DGII antes de cancelar la factura fiscal \"%(invoice)s\"."
msgctxt "model:ir.message,text:msg_inactive_ncf_sequence" msgctxt "model:ir.message,text:msg_inactive_ncf_sequence"
msgid "The NCF sequence selected for invoice \"%(invoice)s\" is inactive." msgid "The NCF sequence selected for invoice \"%(invoice)s\" is inactive."
msgstr "La secuencia NCF seleccionada para la factura \"%(invoice)s\" está inactiva." msgstr "La secuencia NCF seleccionada para la factura \"%(invoice)s\" está inactiva."
+6
View File
@@ -84,6 +84,12 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.message" id="msg_missing_debit_note_sequence"> <record model="ir.message" id="msg_missing_debit_note_sequence">
<field name="text">No active debit note sequence available for type "%(ncf_type)s" in company "%(company)s".</field> <field name="text">No active debit note sequence available for type "%(ncf_type)s" in company "%(company)s".</field>
</record> </record>
<record model="ir.message" id="msg_missing_ncf_cancellation_type">
<field name="text">You must select a DGII cancellation type before cancelling fiscal invoice "%(invoice)s".</field>
</record>
<record model="ir.message" id="msg_missing_ncf_cancellation_reason">
<field name="text">You must enter a DGII cancellation reason before cancelling fiscal invoice "%(invoice)s".</field>
</record>
<record model="ir.message" id="msg_inactive_ncf_sequence"> <record model="ir.message" id="msg_inactive_ncf_sequence">
<field name="text">The NCF sequence selected for invoice "%(invoice)s" is inactive.</field> <field name="text">The NCF sequence selected for invoice "%(invoice)s" is inactive.</field>
</record> </record>
+31
View File
@@ -3,6 +3,7 @@ from decimal import Decimal
from types import SimpleNamespace from types import SimpleNamespace
import unittest import unittest
from trytond.exceptions import UserError
from trytond.modules.ncf_manager.sequence import Sequence from trytond.modules.ncf_manager.sequence import Sequence
from trytond.modules.ncf_manager.invoice import Invoice from trytond.modules.ncf_manager.invoice import Invoice
from trytond.pool import Pool from trytond.pool import Pool
@@ -236,6 +237,36 @@ class NcfManagerUnitTestCase(unittest.TestCase):
self.assertEqual(Sequence.on_change_with_approved_qty(sequence), 10) self.assertEqual(Sequence.on_change_with_approved_qty(sequence), 10)
self.assertEqual(Sequence.on_change_with_remaining_qty(sequence), 7) self.assertEqual(Sequence.on_change_with_remaining_qty(sequence), 7)
def test_fiscal_cancellation_requires_ncf_number(self):
fiscal_invoice = SimpleNamespace(type='out', ncf_number='B0100000001')
non_fiscal_invoice = SimpleNamespace(type='out', ncf_number=None)
supplier_invoice = SimpleNamespace(type='in', ncf_number='B1100000001')
self.assertTrue(
Invoice._ncf_is_fiscal_cancellation_required(fiscal_invoice))
self.assertFalse(
Invoice._ncf_is_fiscal_cancellation_required(non_fiscal_invoice))
self.assertFalse(
Invoice._ncf_is_fiscal_cancellation_required(supplier_invoice))
def test_fiscal_cancellation_information_is_required(self):
invoice = SimpleNamespace(
type='out',
rec_name='INV/1',
ncf_number='B0100000001',
ncf_cancellation_type=None,
ncf_cancellation_reason=None)
with self.assertRaises(UserError):
Invoice._ncf_check_cancellation_information(invoice)
invoice.ncf_cancellation_type = '04'
with self.assertRaises(UserError):
Invoice._ncf_check_cancellation_information(invoice)
invoice.ncf_cancellation_reason = 'Corrección de la información'
Invoice._ncf_check_cancellation_information(invoice)
def test_debit_note_code_follows_origin_ncf_type(self): def test_debit_note_code_follows_origin_ncf_type(self):
original_enabled = Invoice._ncf_electronic_invoicing_enabled original_enabled = Invoice._ncf_electronic_invoicing_enabled
Invoice._ncf_electronic_invoicing_enabled = classmethod( Invoice._ncf_electronic_invoicing_enabled = classmethod(
+7
View File
@@ -94,5 +94,12 @@ this repository contains the full copyright notices and license terms. -->
<newline/> <newline/>
<newline/> <newline/>
</page> </page>
<page id="ncf_cancellation" string="NCF Cancellation">
<label name="ncf_cancellation_type"/>
<field name="ncf_cancellation_type" widget="selection"/>
<newline/>
<separator name="ncf_cancellation_reason" colspan="4"/>
<field name="ncf_cancellation_reason" colspan="4"/>
</page>
</xpath> </xpath>
</data> </data>