diff --git a/invoice.py b/invoice.py
index 00eb716..d4a0380 100755
--- a/invoice.py
+++ b/invoice.py
@@ -7,9 +7,9 @@ from sql.aggregate import Sum
from sql.conditionals import Coalesce
from sql.functions import Round
from trytond import backend
-from trytond.model import fields
+from trytond.model import ModelView, Workflow, fields
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.exceptions import UserError
from trytond.i18n import gettext
@@ -293,6 +293,43 @@ class Invoice(metaclass=PoolMeta):
isr_withholding_type_string = isr_withholding_type.translated(
'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
def __register__(cls, module_name):
"""
@@ -308,6 +345,7 @@ class Invoice(metaclass=PoolMeta):
cls._ncf_prepare_legacy_invoice_ncf_type_column()
super().__register__(module_name)
cls._ncf_migrate_invoice_ncf_type_from_legacy_column()
+ cls._ncf_migrate_legacy_cancellation_fields()
@classmethod
def _ncf_column_exists(cls, cursor, table_name, column_name):
@@ -447,6 +485,35 @@ class Invoice(metaclass=PoolMeta):
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
def search_rec_name(cls, name, clause):
domain = super().search_rec_name(name, clause)
@@ -1645,6 +1712,25 @@ class Invoice(metaclass=PoolMeta):
raise UserError(gettext(
'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
def copy(cls, invoices, default=None):
if default is None:
@@ -1658,6 +1744,8 @@ class Invoice(metaclass=PoolMeta):
default.setdefault('ncf_number_modified', None)
default.setdefault('expiration_date', 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)
@@ -1674,11 +1762,20 @@ class Invoice(metaclass=PoolMeta):
('/form/notebook/page[@id="tax_information"]/separator[@id="expense"]', 'states', {
'invisible': Eval('type') == 'out',
}),
- ('/form/notebook/page[@id="cancel_reason"]', 'states', {
- 'invisible': Eval('type') == 'in',
+ ('/form/notebook/page[@id="ncf_cancellation"]', 'states', {
+ '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
def _post(cls, invoices):
diff --git a/locale/es_419.po b/locale/es_419.po
index e05ab7b..d6777e5 100644
--- a/locale/es_419.po
+++ b/locale/es_419.po
@@ -139,6 +139,14 @@ msgctxt "field:account.invoice,ncf_type_name:"
msgid "NCF Type"
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:"
msgid "Credit Note"
msgstr "Nota de Crédito"
@@ -175,6 +183,46 @@ msgctxt "selection:account.invoice,ncf_type:"
msgid "Valid Invoice for Tax Credit"
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:"
msgid "NCF Sequences"
msgstr "Secuencias de NCF"
@@ -183,6 +231,10 @@ msgctxt "view:account.invoice:"
msgid "Tax Information"
msgstr "Información Fiscal"
+msgctxt "view:account.invoice:"
+msgid "NCF Cancellation"
+msgstr "Anulación NCF"
+
msgctxt "view:account.invoice:"
msgid "Revenue"
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\"."
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"
msgid "The NCF sequence selected for invoice \"%(invoice)s\" is inactive."
msgstr "La secuencia NCF seleccionada para la factura \"%(invoice)s\" está inactiva."
diff --git a/message.xml b/message.xml
index 28ee3b9..9ba0216 100644
--- a/message.xml
+++ b/message.xml
@@ -84,6 +84,12 @@ this repository contains the full copyright notices and license terms. -->
No active debit note sequence available for type "%(ncf_type)s" in company "%(company)s".
+
+ You must select a DGII cancellation type before cancelling fiscal invoice "%(invoice)s".
+
+
+ You must enter a DGII cancellation reason before cancelling fiscal invoice "%(invoice)s".
+
The NCF sequence selected for invoice "%(invoice)s" is inactive.
diff --git a/tests/test_module.py b/tests/test_module.py
index f9fadeb..4c50e5f 100644
--- a/tests/test_module.py
+++ b/tests/test_module.py
@@ -3,6 +3,7 @@ from decimal import Decimal
from types import SimpleNamespace
import unittest
+from trytond.exceptions import UserError
from trytond.modules.ncf_manager.sequence import Sequence
from trytond.modules.ncf_manager.invoice import Invoice
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_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):
original_enabled = Invoice._ncf_electronic_invoicing_enabled
Invoice._ncf_electronic_invoicing_enabled = classmethod(
diff --git a/view/invoice_form.xml b/view/invoice_form.xml
index dcf1d83..d17c2e0 100755
--- a/view/invoice_form.xml
+++ b/view/invoice_form.xml
@@ -94,5 +94,12 @@ this repository contains the full copyright notices and license terms. -->
+
+
+
+
+
+
+