From 60bbed6de0b843de63bf8a9b63308466d2de2244 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Arturo=20Garci=CC=81a?= Date: Sun, 16 Aug 2026 13:52:53 -0400 Subject: [PATCH] Add protected NCF authorization workflow --- README.rst | 14 + invoice.py | 12 +- locale/es.po | 92 +++++++ locale/es_419.po | 101 ++++++- message.xml | 33 +++ ncf_manager.py | 394 ++++++++++++++++++++++++++-- ncf_manager.xml | 32 +++ sequence.py | 4 + tests/test_module.py | 40 +++ view/ncf_manager_form.xml | 38 ++- view/ncf_manager_sequences_form.xml | 21 +- view/ncf_manager_sequences_tree.xml | 7 + view/ncf_manager_tree.xml | 2 + 13 files changed, 762 insertions(+), 28 deletions(-) diff --git a/README.rst b/README.rst index 758a615..d02fa86 100644 --- a/README.rst +++ b/README.rst @@ -28,3 +28,17 @@ Configure this repository secret: The module assigns fiscal numbers transactionally from ``ir.sequence.strict``. It does not sign or transmit e-CF documents; transmission remains the explicit responsibility of an electronic invoicing connector. + +Authorization workflow +********************** + +NCF authorizations follow the protected workflow ``Draft → Active → +Finished``. Create the authorization and its fiscal lines in draft, then use +the **Activate** button. A line can select an existing strict sequence or ask +the module to create one on activation from its authorized range. Activation +checks company, fiscal type, prefix, padding, validity, availability and range +overlap. The last issued number automatically finishes the line. Finishing +an authorization closes every remaining active line without reusing numbers. + +Electronic fiscal numbers are only reserved by this module. Activating an +E-series authorization never signs or transmits an e-CF to DGII. diff --git a/invoice.py b/invoice.py index f73168c..b5c5220 100755 --- a/invoice.py +++ b/invoice.py @@ -1628,7 +1628,17 @@ class Invoice(metaclass=PoolMeta): raise UserError(gettext( 'ncf_manager.msg_ncf_sequence_expired')) - return strict_sequence.get(), strict_sequence.id + number = strict_sequence.get() + StrictSequence = Pool().get('ir.sequence.strict') + ManagerSequence = Pool().get('ncf.manager.sequence') + strict_sequence = StrictSequence(strict_sequence.id) + if (strict_sequence.until_number is not None + and strict_sequence.number_next is not None + and strict_sequence.number_next + > strict_sequence.until_number + and ncf_sequence.state == 'active'): + ManagerSequence.complete([ncf_sequence]) + return number, strict_sequence.id def check_credit_note(self): diff --git a/locale/es.po b/locale/es.po index cc42596..1970946 100644 --- a/locale/es.po +++ b/locale/es.po @@ -513,3 +513,95 @@ msgstr "Borrador" msgctxt "selection:ncf.manager.sequence,state:finished" msgid "Finished" msgstr "Finalizado" + +msgctxt "field:ncf.manager,activated_by:" +msgid "Activated By" +msgstr "Activada por" + +msgctxt "field:ncf.manager,activated_at:" +msgid "Activated At" +msgstr "Fecha de activación" + +msgctxt "field:ncf.manager,finished_by:" +msgid "Finished By" +msgstr "Finalizada por" + +msgctxt "field:ncf.manager,finished_at:" +msgid "Finished At" +msgstr "Fecha de finalización" + +msgctxt "field:ncf.manager.sequence,sequence_mode:" +msgid "Sequence Configuration" +msgstr "Configuración de secuencia" + +msgctxt "field:ncf.manager.sequence,ncf_sequences:" +msgid "Fiscal Sequence" +msgstr "Secuencia fiscal" + +msgctxt "field:ncf.manager.sequence,expected_prefix:" +msgid "Expected Prefix" +msgstr "Prefijo esperado" + +msgctxt "field:ncf.manager.sequence,expected_padding:" +msgid "Expected Padding" +msgstr "Longitud del consecutivo" + +msgctxt "field:ncf.manager.sequence,from_number:" +msgid "Authorized From" +msgstr "Desde autorizado" + +msgctxt "field:ncf.manager.sequence,until_number:" +msgid "Authorized Until" +msgstr "Hasta autorizado" + +msgctxt "field:ncf.manager.sequence,number_next:" +msgid "First Number to Issue" +msgstr "Primer número por emitir" + +msgctxt "field:ncf.manager.sequence,expiration_date:" +msgid "Expiration Date" +msgstr "Fecha de vencimiento" + +msgctxt "field:ncf.manager.sequence,availability:" +msgid "Availability" +msgstr "Disponibilidad" + +msgctxt "selection:ncf.manager.sequence,sequence_mode:existing" +msgid "Select Existing Sequence" +msgstr "Seleccionar secuencia existente" + +msgctxt "selection:ncf.manager.sequence,sequence_mode:create" +msgid "Create Sequence on Activation" +msgstr "Crear secuencia al activar" + +msgctxt "selection:ncf.manager.sequence,availability:available" +msgid "Available" +msgstr "Disponible" + +msgctxt "selection:ncf.manager.sequence,availability:low" +msgid "Running Low" +msgstr "Próxima a agotarse" + +msgctxt "selection:ncf.manager.sequence,availability:expired" +msgid "Expired" +msgstr "Vencida" + +msgctxt "selection:ncf.manager.sequence,availability:exhausted" +msgid "Exhausted" +msgstr "Agotada" + +msgctxt "model:ir.model.button,string:ncf_manager_activate_button" +msgid "Activate" +msgstr "Activar" + +msgctxt "model:ir.model.button,string:ncf_manager_finish_button" +msgid "Finish" +msgstr "Finalizar" + +msgctxt "model:ir.model.button,string:ncf_sequence_finish_button" +msgid "Finish Sequence" +msgstr "Finalizar secuencia" + +msgctxt "view:ncf.manager:" +msgid "Authorized Fiscal Sequences" +msgstr "Secuencias fiscales autorizadas" diff --git a/locale/es_419.po b/locale/es_419.po index 4bfae75..7d72390 100644 --- a/locale/es_419.po +++ b/locale/es_419.po @@ -1,6 +1,13 @@ -msgctxt "selection:account.invoice,isr_withholding_type:" msgid "" msgstr "" +"Project-Id-Version: ncf_manager 8.0\n" +"PO-Revision-Date: 2026-08-16 00:00-0400\n" +"Last-Translator: Fundación Un País Mejor\n" +"Language-Team: Spanish (Latin America)\n" +"Language: es_419\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" msgctxt "field:account.configuration,default_customer_ncf_type:" msgid "Default Customer NCF Type" @@ -1021,3 +1028,95 @@ msgstr "Ingreso" msgctxt "view:account.invoice:" msgid "Tax Information" msgstr "Información Fiscal" + +msgctxt "field:ncf.manager,activated_by:" +msgid "Activated By" +msgstr "Activada por" + +msgctxt "field:ncf.manager,activated_at:" +msgid "Activated At" +msgstr "Fecha de activación" + +msgctxt "field:ncf.manager,finished_by:" +msgid "Finished By" +msgstr "Finalizada por" + +msgctxt "field:ncf.manager,finished_at:" +msgid "Finished At" +msgstr "Fecha de finalización" + +msgctxt "field:ncf.manager.sequence,sequence_mode:" +msgid "Sequence Configuration" +msgstr "Configuración de secuencia" + +msgctxt "field:ncf.manager.sequence,ncf_sequences:" +msgid "Fiscal Sequence" +msgstr "Secuencia fiscal" + +msgctxt "field:ncf.manager.sequence,expected_prefix:" +msgid "Expected Prefix" +msgstr "Prefijo esperado" + +msgctxt "field:ncf.manager.sequence,expected_padding:" +msgid "Expected Padding" +msgstr "Longitud del consecutivo" + +msgctxt "field:ncf.manager.sequence,from_number:" +msgid "Authorized From" +msgstr "Desde autorizado" + +msgctxt "field:ncf.manager.sequence,until_number:" +msgid "Authorized Until" +msgstr "Hasta autorizado" + +msgctxt "field:ncf.manager.sequence,number_next:" +msgid "First Number to Issue" +msgstr "Primer número por emitir" + +msgctxt "field:ncf.manager.sequence,expiration_date:" +msgid "Expiration Date" +msgstr "Fecha de vencimiento" + +msgctxt "field:ncf.manager.sequence,availability:" +msgid "Availability" +msgstr "Disponibilidad" + +msgctxt "selection:ncf.manager.sequence,sequence_mode:existing" +msgid "Select Existing Sequence" +msgstr "Seleccionar secuencia existente" + +msgctxt "selection:ncf.manager.sequence,sequence_mode:create" +msgid "Create Sequence on Activation" +msgstr "Crear secuencia al activar" + +msgctxt "selection:ncf.manager.sequence,availability:available" +msgid "Available" +msgstr "Disponible" + +msgctxt "selection:ncf.manager.sequence,availability:low" +msgid "Running Low" +msgstr "Próxima a agotarse" + +msgctxt "selection:ncf.manager.sequence,availability:expired" +msgid "Expired" +msgstr "Vencida" + +msgctxt "selection:ncf.manager.sequence,availability:exhausted" +msgid "Exhausted" +msgstr "Agotada" + +msgctxt "model:ir.model.button,string:ncf_manager_activate_button" +msgid "Activate" +msgstr "Activar" + +msgctxt "model:ir.model.button,string:ncf_manager_finish_button" +msgid "Finish" +msgstr "Finalizar" + +msgctxt "model:ir.model.button,string:ncf_sequence_finish_button" +msgid "Finish Sequence" +msgstr "Finalizar secuencia" + +msgctxt "view:ncf.manager:" +msgid "Authorized Fiscal Sequences" +msgstr "Secuencias fiscales autorizadas" diff --git a/message.xml b/message.xml index 8fef676..415b728 100644 --- a/message.xml +++ b/message.xml @@ -126,5 +126,38 @@ this repository contains the full copyright notices and license terms. --> The next fiscal number cannot be before the authorized range. + + Add at least one fiscal sequence before activating the authorization. + + + Application and authorization numbers are required before activation. + + + This authorization cannot be deleted because one of its fiscal sequences has been used on an invoice. + + + This fiscal sequence cannot be deleted because it has been used on an invoice. + + + The selected sequence type does not correspond to the selected NCF type. + + + The “Invoice without NCF” classification does not create a fiscal sequence. + + + Starting, ending and first issue numbers are required to create the fiscal sequence. + + + The authorized range overlaps an active range for the same company and NCF type. + + + The fiscal sequence is already assigned to another authorization line. + + + Only draft fiscal sequence lines can be deleted. + + + Only draft fiscal authorizations can be deleted. + diff --git a/ncf_manager.py b/ncf_manager.py index 0eff543..b262d13 100644 --- a/ncf_manager.py +++ b/ncf_manager.py @@ -1,8 +1,12 @@ -# This file is part of ncf_manager module for Tryton. The COPYRIGHT file at the top level of -# this repository contains the full copyright notices and license terms. +import datetime + +# This file is part of ncf_manager module for Tryton. The COPYRIGHT file at +# the top level of this repository contains the full copyright notices and +# license terms. from trytond.exceptions import UserError from trytond.i18n import gettext -from trytond.model import ModelView, ModelSQL, fields + +from trytond.model import ModelView, ModelSQL, Workflow, fields, Unique from trytond.pool import Pool from trytond.pyson import Eval from trytond.transaction import Transaction @@ -34,8 +38,48 @@ NCF_TYPE = [ ('99', 'Factura sin NCF'), ] +ELECTRONIC_NCF_TYPES = frozenset({ + '31', '32', '33', '34', '41', '43', '44', '45', '46', '47'}) -class NCFManager(ModelSQL, ModelView): +NCF_SEQUENCE_TYPE_IDS = { + '1': 'sequence_type_out_invoice_01', + '2': 'sequence_type_out_invoice_02', + '3': 'sequence_type_out_invoice_03', + '4': 'sequence_type_out_credit_note_04', + '11': 'sequence_type_out_invoice_11', + '12': 'sequence_type_out_invoice_12', + '13': 'sequence_type_out_invoice_13', + '14': 'sequence_type_out_invoice_14', + '15': 'sequence_type_out_invoice_15', + '16': 'sequence_type_out_invoice_16', + '17': 'sequence_type_out_invoice_17', + '31': 'sequence_type_out_invoice_31', + '32': 'sequence_type_out_invoice_32', + '33': 'sequence_type_out_invoice_33', + '34': 'sequence_type_out_credit_note_34', + '41': 'sequence_type_out_invoice_41', + '43': 'sequence_type_out_invoice_43', + '44': 'sequence_type_out_invoice_44', + '45': 'sequence_type_out_invoice_45', + '46': 'sequence_type_out_invoice_46', + '47': 'sequence_type_out_invoice_47', +} + + +def ncf_prefix(ncf_type): + if not ncf_type or ncf_type == '99': + return None + series = 'E' if ncf_type in ELECTRONIC_NCF_TYPES else 'B' + return f'{series}{ncf_type.zfill(2)}' + + +def ncf_padding(ncf_type): + if not ncf_type or ncf_type == '99': + return 0 + return 10 if ncf_type in ELECTRONIC_NCF_TYPES else 8 + + +class NCFManager(Workflow, ModelSQL, ModelView): 'NCF Manager' __name__ = 'ncf.manager' @@ -66,10 +110,17 @@ class NCFManager(ModelSQL, ModelView): ('draft', 'Draft'), ('active', 'Active'), ('finished', 'Finished'), - ], "State", sort=False) + ], "State", readonly=True, required=True, sort=False) state_string = state.translated('state') + activated_by = fields.Many2One( + 'res.user', 'Activated By', readonly=True) + activated_at = fields.DateTime('Activated At', readonly=True) + finished_by = fields.Many2One( + 'res.user', 'Finished By', readonly=True) + finished_at = fields.DateTime('Finished At', readonly=True) + del _states, _depends @classmethod @@ -79,6 +130,20 @@ class NCFManager(ModelSQL, ModelView): ('app_date', 'DESC'), ('id', 'DESC'), ] + cls._transitions |= { + ('draft', 'active'), + ('active', 'finished'), + } + cls._buttons.update({ + 'activate': { + 'invisible': Eval('state') != 'draft', + 'depends': ['state'], + }, + 'finish': { + 'invisible': Eval('state') != 'active', + 'depends': ['state'], + }, + }) def get_rec_name(self, name): parts = [] @@ -96,8 +161,79 @@ class NCFManager(ModelSQL, ModelView): def default_state(): return 'draft' + @classmethod + @ModelView.button + @Workflow.transition('active') + def activate(cls, managers): + Line = Pool().get('ncf.manager.sequence') + now = datetime.datetime.now() + user = Transaction().user + lines = [] + for manager in managers: + if not manager.ncf_sequences: + raise UserError(gettext( + 'ncf_manager.msg_ncf_manager_without_sequences')) + manager._validate_authorization() + for line in manager.ncf_sequences: + line.prepare_sequence() + line._validate_fiscal_sequence() + line._validate_no_overlap() + lines.append(line) + if lines: + Line.save(lines) + Line.activate(lines) + cls.write(managers, { + 'activated_by': user, + 'activated_at': now, + }) -class NCFManagerSequences(ModelSQL, ModelView): + def _validate_authorization(self): + if not self.app_number.strip() or not self.auth_number.strip(): + raise UserError(gettext( + 'ncf_manager.msg_ncf_missing_authorization')) + + @classmethod + @ModelView.button + @Workflow.transition('finished') + def finish(cls, managers): + Line = Pool().get('ncf.manager.sequence') + lines = [line for manager in managers + for line in manager.ncf_sequences if line.state == 'active'] + if lines: + Line.complete(lines) + cls.write(managers, { + 'finished_by': Transaction().user, + 'finished_at': datetime.datetime.now(), + }) + + @classmethod + def delete(cls, managers): + if any(manager.state != 'draft' for manager in managers): + raise UserError(gettext( + 'ncf_manager.msg_ncf_authorization_delete_not_draft')) + Invoice = Pool().get('account.invoice') + if Invoice.search([ + ('ncf_sequence.pos', 'in', [m.id for m in managers]), + ], limit=1): + raise UserError(gettext( + 'ncf_manager.msg_ncf_authorization_used')) + super().delete(managers) + + @classmethod + def copy(cls, managers, default=None): + default = dict(default or {}) + default.update({ + 'state': 'draft', + 'ncf_sequences': None, + 'activated_by': None, + 'activated_at': None, + 'finished_by': None, + 'finished_at': None, + }) + return super().copy(managers, default=default) + + +class NCFManagerSequences(Workflow, ModelSQL, ModelView): 'NCF Manager Sequences' __name__ = 'ncf.manager.sequence' @@ -118,11 +254,60 @@ class NCFManagerSequences(ModelSQL, ModelView): ncf_type_string = ncf_type.translated('ncf_type') - ncf_sequences = fields.Many2One('ir.sequence.strict', 'NCF Sequences', - domain=[ - ('company', '=', Eval( - 'context', {}).get('company', -1)), - ], states=_states, depends=_depends) + company = fields.Function( + fields.Many2One('company.company', 'Company'), + 'on_change_with_company') + + expected_prefix = fields.Function( + fields.Char('Expected Prefix'), 'on_change_with_expected_prefix') + expected_padding = fields.Function( + fields.Integer('Expected Padding'), + 'on_change_with_expected_padding') + + sequence_mode = fields.Selection([ + ('existing', 'Select Existing Sequence'), + ('create', 'Create Sequence on Activation'), + ], 'Sequence Configuration', required=True, states=_states, + depends=_depends, sort=False) + + ncf_sequences = fields.Many2One('ir.sequence.strict', 'Fiscal Sequence', + domain=[ + ('company', '=', Eval('company', -1)), + ('prefix', '=', Eval('expected_prefix')), + ('active', '=', True), + ], + states={ + **_states, + 'required': Eval('sequence_mode') == 'existing', + 'invisible': Eval('sequence_mode') == 'create', + }, + depends=_depends + [ + 'company', + 'sequence_mode', 'expected_prefix', 'expected_padding']) + + from_number = fields.Integer( + 'Authorized From', states={ + **_states, + 'required': Eval('sequence_mode') == 'create', + 'invisible': Eval('sequence_mode') != 'create', + }, depends=_depends + ['sequence_mode']) + until_number = fields.Integer( + 'Authorized Until', states={ + **_states, + 'required': Eval('sequence_mode') == 'create', + 'invisible': Eval('sequence_mode') != 'create', + }, depends=_depends + ['sequence_mode']) + number_next = fields.Integer( + 'First Number to Issue', states={ + **_states, + 'required': Eval('sequence_mode') == 'create', + 'invisible': Eval('sequence_mode') != 'create', + }, depends=_depends + ['sequence_mode']) + expiration_date = fields.Date( + 'Expiration Date', states={ + **_states, + 'invisible': Eval('sequence_mode') != 'create', + }, depends=_depends + ['sequence_mode']) invoice_type = fields.Selection([ ('invoice_customer', 'Customer Invoice'), @@ -134,16 +319,51 @@ class NCFManagerSequences(ModelSQL, ModelView): ('draft', 'Draft'), ('active', 'Active'), ('finished', 'Finished'), - ], "State", sort=False) + ], "State", readonly=True, required=True, sort=False) state_string = state.translated('state') + availability = fields.Function(fields.Selection([ + ('available', 'Available'), + ('low', 'Running Low'), + ('expired', 'Expired'), + ('exhausted', 'Exhausted'), + ], 'Availability', sort=False), 'get_availability') + availability_string = availability.translated('availability') + finished_by = fields.Many2One( + 'res.user', 'Finished By', readonly=True) + finished_at = fields.DateTime('Finished At', readonly=True) + del _states, _depends @staticmethod def default_state(): return 'draft' + @staticmethod + def default_sequence_mode(): + return 'existing' + + @fields.depends('pos', '_parent_pos.company') + def on_change_with_company(self, name=None): + return self.pos.company.id if self.pos and self.pos.company else None + + @fields.depends('ncf_type') + def on_change_with_expected_prefix(self, name=None): + return ncf_prefix(self.ncf_type) + + @fields.depends('ncf_type') + def on_change_with_expected_padding(self, name=None): + return ncf_padding(self.ncf_type) + + @fields.depends( + 'sequence_mode', 'ncf_type', 'ncf_sequences', + 'from_number', 'until_number', 'number_next', 'expiration_date') + def on_change_ncf_type(self): + if (self.ncf_sequences + and self.ncf_sequences.prefix != ncf_prefix(self.ncf_type)): + self.ncf_sequences = None + def get_rec_name(self, name): if not self.ncf_type_string: return '' @@ -160,13 +380,157 @@ class NCFManagerSequences(ModelSQL, ModelView): sequence = self.ncf_sequences if not sequence: return + if self.ncf_type == '99': + raise UserError(gettext( + 'ncf_manager.msg_ncf_non_fiscal_sequence')) if sequence.company != self.pos.company: raise UserError(gettext( 'ncf_manager.msg_ncf_sequence_company_mismatch')) - expected_prefix = ('E' if self.ncf_type in { - '31', '32', '33', '34', '41', '43', '44', '45', '46', '47' - } else 'B') + self.ncf_type.zfill(2) + expected_prefix = ncf_prefix(self.ncf_type) if self.ncf_type != '99' and sequence.prefix != expected_prefix: raise UserError(gettext( 'ncf_manager.msg_ncf_sequence_prefix_mismatch', prefix=expected_prefix)) + if sequence.padding != ncf_padding(self.ncf_type): + raise UserError(gettext( + 'ncf_manager.msg_ncf_invalid_padding', + padding=ncf_padding(self.ncf_type))) + ModelData = Pool().get('ir.model.data') + expected_type = ModelData.get_id( + 'ncf_manager', NCF_SEQUENCE_TYPE_IDS[self.ncf_type]) + if sequence.sequence_type.id != expected_type: + raise UserError(gettext( + 'ncf_manager.msg_ncf_sequence_type_mismatch')) + today = Transaction().context.get('date') or datetime.date.today() + if sequence.expiration_date and sequence.expiration_date < today: + raise UserError(gettext( + 'ncf_manager.msg_ncf_sequence_expired')) + if (sequence.until_number is not None + and sequence.number_next is not None + and sequence.number_next > sequence.until_number): + raise UserError(gettext( + 'ncf_manager.msg_ncf_sequence_finished')) + + def prepare_sequence(self): + if self.sequence_mode != 'create': + return + if self.ncf_type == '99': + raise UserError(gettext( + 'ncf_manager.msg_ncf_non_fiscal_sequence')) + self._validate_assisted_values() + pool = Pool() + ModelData = pool.get('ir.model.data') + Sequence = pool.get('ir.sequence.strict') + sequence_type = ModelData.get_id( + 'ncf_manager', NCF_SEQUENCE_TYPE_IDS[self.ncf_type]) + sequence, = Sequence.create([{ + 'name': f'{ncf_prefix(self.ncf_type)} - {self.pos.auth_number}', + 'sequence_type': sequence_type, + 'company': self.pos.company.id, + 'prefix': ncf_prefix(self.ncf_type), + 'padding': ncf_padding(self.ncf_type), + 'number_increment': 1, + 'number_next': self.number_next, + 'from_number': self.from_number, + 'until_number': self.until_number, + 'expiration_date': self.expiration_date, + }]) + self.ncf_sequences = sequence + self.sequence_mode = 'existing' + + def _validate_assisted_values(self): + if None in (self.from_number, self.until_number, self.number_next): + raise UserError(gettext( + 'ncf_manager.msg_ncf_assisted_values_required')) + if not (1 <= self.from_number <= self.number_next + <= self.until_number): + raise UserError(gettext('ncf_manager.msg_ncf_invalid_range')) + + def _validate_no_overlap(self): + sequence = self.ncf_sequences + if not sequence: + return + candidates = self.__class__.search([ + ('id', '!=', self.id), + ('pos.company', '=', self.pos.company.id), + ('ncf_type', '=', self.ncf_type), + ('state', '=', 'active'), + ('ncf_sequences', '!=', None), + ]) + for candidate in candidates: + other = candidate.ncf_sequences + if None in (sequence.from_number, sequence.until_number, + other.from_number, other.until_number): + continue + if (sequence.from_number <= other.until_number + and other.from_number <= sequence.until_number): + raise UserError(gettext( + 'ncf_manager.msg_ncf_sequence_overlap')) + + def get_availability(self, name): + sequence = self.ncf_sequences + if not sequence: + return 'available' + today = Transaction().context.get('date') or datetime.date.today() + if sequence.expiration_date and sequence.expiration_date < today: + return 'expired' + if (sequence.until_number is not None + and sequence.number_next is not None): + remaining = sequence.until_number - sequence.number_next + 1 + if remaining <= 0: + return 'exhausted' + approved = ((sequence.until_number - sequence.from_number + 1) + if sequence.from_number is not None else 0) + if remaining <= max(10, int(approved * .1)): + return 'low' + return 'available' + + @classmethod + def __setup__(cls): + super().__setup__() + table = cls.__table__() + cls._sql_constraints += [ + ('fiscal_sequence_unique', Unique(table, table.ncf_sequences), + 'ncf_manager.msg_ncf_sequence_already_assigned'), + ] + cls._transitions |= { + ('draft', 'active'), + ('active', 'finished'), + } + cls._buttons.update({ + 'finish': { + 'invisible': Eval('state') != 'active', + 'depends': ['state'], + }, + }) + + @classmethod + @Workflow.transition('active') + def activate(cls, lines): + pass + + @classmethod + @ModelView.button + def finish(cls, lines): + cls.complete(lines) + + @classmethod + @Workflow.transition('finished') + def complete(cls, lines): + cls.write(lines, { + 'finished_by': Transaction().user, + 'finished_at': datetime.datetime.now(), + }) + + @classmethod + def delete(cls, lines): + if any(line.state != 'draft' for line in lines): + raise UserError(gettext( + 'ncf_manager.msg_ncf_sequence_delete_not_draft')) + Invoice = Pool().get('account.invoice') + if Invoice.search([ + ('ncf_sequence', 'in', [line.id for line in lines]), + ], limit=1): + raise UserError(gettext( + 'ncf_manager.msg_ncf_sequence_used')) + super().delete(lines) diff --git a/ncf_manager.xml b/ncf_manager.xml index d8c873c..3834df5 100644 --- a/ncf_manager.xml +++ b/ncf_manager.xml @@ -57,6 +57,27 @@ this repository contains the full copyright notices and license terms. --> + + activate + Activate + ncf.manager + + + + + + + finish + Finish + ncf.manager + + + + + + ncf.manager.sequence form @@ -86,5 +107,16 @@ this repository contains the full copyright notices and license terms. --> + + finish + Finish Sequence + ncf.manager.sequence + + + + + + diff --git a/sequence.py b/sequence.py index 6ef13b8..41982c0 100644 --- a/sequence.py +++ b/sequence.py @@ -140,4 +140,8 @@ class SequenceStrict(Sequence): __name__ = 'ir.sequence.strict' _table = None # Needed to reset Sequence._table _strict = True + + ncf_authorization_lines = fields.One2Many( + 'ncf.manager.sequence', 'ncf_sequences', 'NCF Authorizations', + readonly=True) diff --git a/tests/test_module.py b/tests/test_module.py index e6831d8..c3e146f 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -216,6 +216,46 @@ class NcfManagerTestCase(ModuleTestCase): self.assertEqual( invoice.expiration_date, datetime.date(today.year, 12, 31)) + @with_transaction() + def test_assisted_sequence_workflow(self): + pool = Pool() + Manager = pool.get('ncf.manager') + today = datetime.date.today() + company = self._create_company() + + manager, = Manager.create([{ + 'company': company.id, + 'app_date': today, + 'app_number': 'APP0000001', + 'auth_number': 'AUT0000001', + 'ncf_sequences': [('create', [{ + 'invoice_type': 'invoice_customer', + 'ncf_type': '1', + 'sequence_mode': 'create', + 'from_number': 100, + 'number_next': 100, + 'until_number': 109, + 'expiration_date': datetime.date(today.year, 12, 31), + }])], + }]) + + Manager.activate([manager]) + manager = Manager(manager.id) + line, = manager.ncf_sequences + self.assertEqual(manager.state, 'active') + self.assertEqual(line.state, 'active') + self.assertEqual(line.sequence_mode, 'existing') + self.assertEqual(line.ncf_sequences.prefix, 'B01') + self.assertEqual(line.ncf_sequences.padding, 8) + self.assertEqual(line.ncf_sequences.number_next, 100) + self.assertEqual(line.availability, 'low') + + Manager.finish([manager]) + manager = Manager(manager.id) + self.assertEqual(manager.state, 'finished') + self.assertEqual(manager.ncf_sequences[0].state, 'finished') + self.assertIsNotNone(manager.finished_at) + del ModuleTestCase diff --git a/view/ncf_manager_form.xml b/view/ncf_manager_form.xml index dbec5d9..b251ca0 100644 --- a/view/ncf_manager_form.xml +++ b/view/ncf_manager_form.xml @@ -3,15 +3,33 @@ The COPYRIGHT file at the top level of this repository contains the full copyright notices and license types. -->
-