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. --> + +