From b96639f3e8ae126ec0d2cc17aa3e7b60e8de247f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Arturo=20Garci=CC=81a?= Date: Sun, 16 Aug 2026 13:36:51 -0400 Subject: [PATCH] Prepare NCF manager for production on Tryton 8.0 --- .gitea/workflows/publish.yml | 190 ++++++----------------------------- COPYRIGHT | 2 +- README.rst | 9 +- invoice.fodt | 8 +- invoice.py | 55 +++++++++- message.xml | 21 ++++ ncf_manager.py | 26 ++++- pyproject.toml | 12 +-- sequence.py | 42 +++++--- tests/test_module.py | 31 +++++- 10 files changed, 204 insertions(+), 192 deletions(-) diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 72ab4f5..0a53ce8 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -1,192 +1,62 @@ -name: Publish Package +name: Publicar paquete PyPI on: push: - branches: - - "**" + branches: ["8.0"] workflow_dispatch: +permissions: + contents: read + packages: write + jobs: publish: runs-on: ubuntu-latest - steps: - name: Checkout - uses: actions/checkout@v4 + uses: https://gitea.com/actions/checkout@v4 + with: + token: ${{ secrets.GITEA_TOKEN }} + github-server-url: http://unpaismejor-gitea-app:3000 + ref: 8.0 + fetch-depth: 0 - - name: Detect package branch - run: | - python - <<'PY' - import os - import re - - branch = ( - os.environ.get('GITHUB_REF_NAME') - or os.environ.get('GITHUB_HEAD_REF') - or '') - publish = bool(re.fullmatch(r'\d+\.\d+', branch)) - with open(os.environ['GITHUB_ENV'], 'a') as env: - env.write(f'PUBLISH_PACKAGE={"true" if publish else "false"}\n') - if publish: - env.write(f'PACKAGE_SERIES={branch}\n') - if publish: - print(f'Publishing package series: {branch}') - else: - print(f'Skipping package publish for branch: {branch}') - PY - - - name: Set up Python - if: env.PUBLISH_PACKAGE == 'true' - uses: actions/setup-python@v5 + - name: Configurar Python + uses: https://gitea.com/actions/setup-python@v5 with: python-version: "3.11" - - name: Install build tools - if: env.PUBLISH_PACKAGE == 'true' - run: | - python -m pip install --upgrade pip - python -m pip install build twine + - name: Instalar herramientas + run: python -m pip install --upgrade "build>=1,<2" "twine>=6,<7" - - name: Check publishing credentials - if: env.PUBLISH_PACKAGE == 'true' + - name: Comprobar credenciales env: - TWINE_USERNAME: ${{ secrets.REGISTRY_USER }} TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} - run: | - test -n "$TWINE_USERNAME" || { echo "Missing REGISTRY_USER secret"; exit 1; } - test -n "$TWINE_PASSWORD" || { echo "Missing REGISTRY_PASSWORD secret"; exit 1; } + run: test -n "$TWINE_PASSWORD" || { echo "Missing REGISTRY_PASSWORD secret"; exit 1; } - - name: Set CI package version - if: env.PUBLISH_PACKAGE == 'true' + - name: Fijar versión CI env: - TWINE_USERNAME: ${{ secrets.REGISTRY_USER }} - TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + PACKAGE_VERSION: 8.0.${{ gitea.run_number }} run: | python - <<'PY' from pathlib import Path - from urllib.error import HTTPError - from urllib.parse import urlencode - from urllib.request import Request, urlopen - import base64 - import json - import os - import re - import tomllib - - OWNER = 'tryton-do' - PACKAGE_TYPE = 'pypi' - API_ROOT = 'https://gitea.joseagrc.com/api/v1' - - def normalize(name): - return re.sub(r'[-_.]+', '-', name).lower() - - cfg_path = Path('tryton.cfg') - cfg_text = cfg_path.read_text() - cfg_match = re.search(r'(?m)^version=(.+)$', cfg_text) - if not cfg_match: - raise SystemExit('tryton.cfg has no version entry') - - series = os.environ['PACKAGE_SERIES'] - major, minor = map(int, series.split('.')) - - pyproject = tomllib.loads(Path('pyproject.toml').read_text()) - package_name = pyproject['project']['name'] - normalized_name = normalize(package_name) - - username = os.environ['TWINE_USERNAME'] - password = os.environ['TWINE_PASSWORD'] - token = base64.b64encode( - f'{username}:{password}'.encode()).decode() - - def request_json(path, params=None, missing_ok=False): - url = f'{API_ROOT}{path}' - if params: - url = f'{url}?{urlencode(params)}' - request = Request(url, headers={ - 'Accept': 'application/json', - 'Authorization': f'Basic {token}', - }) - try: - with urlopen(request, timeout=30) as response: - return json.load(response) - except HTTPError as error: - if missing_ok and error.code == 404: - return [] - raise - - def collect_versions(payload, package_specific=False): - versions = [] - if isinstance(payload, list): - for item in payload: - versions.extend(collect_versions(item, package_specific)) - elif isinstance(payload, dict): - item_name = payload.get('name') or payload.get('package_name') - item_type = payload.get('type') or payload.get('package_type') - item_version = payload.get('version') - if item_version and ( - package_specific - or (item_type == PACKAGE_TYPE - and normalize(item_name or '') == normalized_name)): - versions.append(str(item_version)) - for value in payload.values(): - if isinstance(value, (list, dict)): - versions.extend(collect_versions(value, package_specific)) - return versions - - versions = [] - for page in range(1, 11): - payload = request_json( - f'/packages/{OWNER}/{PACKAGE_TYPE}/{package_name}', - {'page': page, 'limit': 100}, - missing_ok=True) - page_versions = collect_versions(payload, package_specific=True) - versions.extend(page_versions) - if not page_versions: - break - - for page in range(1, 11): - payload = request_json( - f'/packages/{OWNER}', - {'page': page, 'limit': 100, 'type': PACKAGE_TYPE, - 'q': package_name}) - page_versions = collect_versions(payload) - versions.extend(page_versions) - if not page_versions: - break - - patch_pattern = re.compile(rf'^{major}\.{minor}\.(\d+)$') - patches = [] - for version in set(versions): - match = patch_pattern.fullmatch(version) - if match: - patches.append(int(match.group(1))) - - next_patch = max(patches) + 1 if patches else 0 - ci_version = f'{major}.{minor}.{next_patch}' - cfg_text = re.sub( - r'(?m)^version=.+$', f'version={ci_version}', cfg_text) - cfg_path.write_text(cfg_text) - print(f'Package name: {package_name}') - print(f'Published versions found: {sorted(set(versions))}') - print(f'Package version: {ci_version}') + import os, re + path = Path('tryton.cfg') + text = path.read_text() + path.write_text(re.sub(r'(?m)^version=.*$', + f"version={os.environ['PACKAGE_VERSION']}", text)) PY - - name: Build package - if: env.PUBLISH_PACKAGE == 'true' + - name: Construir y validar run: | python -m build - - - name: Check package - if: env.PUBLISH_PACKAGE == 'true' - run: | python -m twine check dist/* - - name: Publish to Gitea PyPI registry - if: env.PUBLISH_PACKAGE == 'true' + - name: Publicar en Gitea PyPI env: - TWINE_USERNAME: ${{ secrets.REGISTRY_USER }} + TWINE_USERNAME: unpaismejor TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | - python -m twine upload \ - --repository-url https://gitea.joseagrc.com/api/packages/tryton-do/pypi \ + python -m twine upload --non-interactive \ + --repository-url https://code.unpaismejor.org.do/api/packages/tryton-do/pypi \ dist/* diff --git a/COPYRIGHT b/COPYRIGHT index 738fa66..7f79d20 100644 --- a/COPYRIGHT +++ b/COPYRIGHT @@ -1,4 +1,4 @@ -Copyright (C) 2022 SOLUTEMA SRL +Copyright (C) 2022-2026 Fundación Un País Mejor This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by diff --git a/README.rst b/README.rst index d17fab1..758a615 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ Installing Install the package and activate the module in the database:: - pip install trytond_ncf_manager + pip install unpaismejor_ncf_manager trytond-admin -d -u ncf_manager Publishing @@ -21,7 +21,10 @@ Publishing The included Gitea Actions workflow builds and publishes the package to the Gitea PyPI registry from version-series branches such as ``8.0``. -Configure these repository secrets: +Configure this repository secret: -* ``REGISTRY_USER`` * ``REGISTRY_PASSWORD`` + +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. diff --git a/invoice.fodt b/invoice.fodt index ef85b88..fcebb8b 100644 --- a/invoice.fodt +++ b/invoice.fodt @@ -2046,7 +2046,7 @@ - <if test="invoice.tip_invoiced > 0"> + <if test="invoice.tip > 0"> @@ -2055,7 +2055,7 @@ Propina: - <format_currency(invoice.tip_invoiced, invoice.party.lang, invoice.currency)> + <format_currency(invoice.tip, invoice.party.lang, invoice.currency)> @@ -2835,7 +2835,7 @@ - <if test="invoice.tip_invoiced > 0"> + <if test="invoice.tip > 0"> @@ -2844,7 +2844,7 @@ Propina: - <format_currency(invoice.tip_invoiced, invoice.party.lang, invoice.currency)> + <format_currency(invoice.tip, invoice.party.lang, invoice.currency)> diff --git a/invoice.py b/invoice.py index d4a0380..f73168c 100755 --- a/invoice.py +++ b/invoice.py @@ -3,6 +3,7 @@ # this repository contains the full copyright notices and license terms. from decimal import Decimal from datetime import date +import re from sql.aggregate import Sum from sql.conditionals import Coalesce from sql.functions import Round @@ -1563,7 +1564,7 @@ class Invoice(metaclass=PoolMeta): ncf_code = invoice.ncf_type - if invoice.type == 'in' and ncf_code == '99': + if ncf_code == '99': continue ncf_sequence = invoice.ncf_sequence or cls._ncf_find_active_sequence( @@ -1712,6 +1713,53 @@ class Invoice(metaclass=PoolMeta): raise UserError(gettext( 'ncf_manager.msg_incorrect_lenght_ncf_number_modified')) + @staticmethod + def _ncf_normalize_number(number): + return (number or '').strip().upper() + + @classmethod + def _ncf_validate_number(cls, invoice): + number = cls._ncf_normalize_number(invoice.ncf_number) + if not number: + return + legacy_supplier_ncf = ( + invoice.type == 'in' and re.fullmatch(r'[A-Z]\d{18}', number)) + if not legacy_supplier_ncf and not re.fullmatch( + r'(?:B\d{10}|E\d{12})', number): + raise UserError(gettext( + 'ncf_manager.msg_invalid_ncf_structure', number=number)) + if legacy_supplier_ncf: + invoice.ncf_number = number + return + expected = ('E' if invoice.ncf_type in _ECF_CODES else 'B') + \ + invoice.ncf_type.zfill(2) + if invoice.ncf_type != '99' and not number.startswith(expected): + raise UserError(gettext( + 'ncf_manager.msg_invalid_ncf_type_prefix', + number=number, prefix=expected)) + invoice.ncf_number = number + + @classmethod + def _ncf_check_duplicate(cls, invoice): + if not invoice.ncf_number: + return + domain = [ + ('id', '!=', invoice.id), + ('company', '=', invoice.company.id), + ('ncf_number', '=', invoice.ncf_number), + ] + # Supplier NCF sequences are unique per issuer, not globally. + if invoice.type == 'in': + domain.extend([ + ('type', '=', 'in'), + ('party', '=', invoice.party.id), + ]) + else: + domain.append(('type', '=', 'out')) + if cls.search(domain, limit=1): + raise UserError(gettext( + 'ncf_manager.msg_duplicated_ncf_number_invoice')) + @staticmethod def _ncf_is_fiscal_cancellation_required(invoice): return ( @@ -1782,6 +1830,8 @@ class Invoice(metaclass=PoolMeta): cls.set_ncf_number(invoices) for invoice in invoices: + cls._ncf_validate_number(invoice) + cls._ncf_check_duplicate(invoice) ncf_code = invoice.ncf_type is_debit_note = cls._ncf_is_debit_note(invoice) is_credit_note = cls._ncf_is_credit_note(invoice) @@ -1789,7 +1839,8 @@ class Invoice(metaclass=PoolMeta): is_without_ncf = ncf_code == '99' if invoice.type == 'out': - invoice._ncf_validate_selected_sequence() + if not is_without_ncf: + invoice._ncf_validate_selected_sequence() if is_credit_note: invoice.check_credit_note() invoice.check_ncf_number_modified() diff --git a/message.xml b/message.xml index 9ba0216..8fef676 100644 --- a/message.xml +++ b/message.xml @@ -105,5 +105,26 @@ this repository contains the full copyright notices and license terms. --> Partial credit note refund is not allowed. + + The fiscal number "%(number)s" must have the DGII B + 10 digits or E + 12 digits structure. + + + The fiscal number "%(number)s" does not match the expected prefix "%(prefix)s". + + + The fiscal sequence and its authorization must belong to the same company. + + + The fiscal sequence must use prefix "%(prefix)s" for the selected NCF type. + + + The fiscal sequence must use a padding of %(padding)s digits. + + + The authorized fiscal range must start at 1 or greater and end at or after its start. + + + The next fiscal number cannot be before the authorized range. + diff --git a/ncf_manager.py b/ncf_manager.py index 0c36e06..0eff543 100644 --- a/ncf_manager.py +++ b/ncf_manager.py @@ -1,8 +1,10 @@ # 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.pool import Pool -from trytond.pyson import Eval, Id +from trytond.pyson import Eval from trytond.transaction import Transaction @@ -146,3 +148,25 @@ class NCFManagerSequences(ModelSQL, ModelView): if not self.ncf_type_string: return '' return self.ncf_type_string.split('-')[0] + + @classmethod + def validate_fields(cls, records, field_names): + super().validate_fields(records, field_names) + if field_names & {'pos', 'ncf_sequences', 'ncf_type'}: + for record in records: + record._validate_fiscal_sequence() + + def _validate_fiscal_sequence(self): + sequence = self.ncf_sequences + if not sequence: + return + 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) + if self.ncf_type != '99' and sequence.prefix != expected_prefix: + raise UserError(gettext( + 'ncf_manager.msg_ncf_sequence_prefix_mismatch', + prefix=expected_prefix)) diff --git a/pyproject.toml b/pyproject.toml index 2c142c2..9a30535 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,13 @@ [build-system] -requires = ['hatchling >= 1', 'hatch-tryton'] +requires = ['hatchling >=1,<1.31', 'hatch-tryton'] build-backend = 'hatchling.build' [project] -name = 'trytond_ncf_manager' +name = 'unpaismejor_ncf_manager' dynamic = ['version', 'dependencies', 'optional-dependencies', 'authors', 'readme'] -requires-python = '>=3.10' +requires-python = '>=3.11' maintainers = [ - {name = "Solutema"}, + {name = "Fundación Un País Mejor"}, ] description = "NCF management for Tryton" license = 'GPL-3.0-or-later' @@ -26,8 +26,8 @@ classifiers = [ ncf_manager = 'trytond.modules.ncf_manager' [project.urls] -homepage = "https://www.tryton.org/" -repository = "https://gitea.joseagrc.com/tryton-do/ncf_manager" +homepage = "https://code.unpaismejor.org.do/tryton-do/ncf_manager" +repository = "https://code.unpaismejor.org.do/tryton-do/ncf_manager.git" [tool.hatch.build] include = [ diff --git a/sequence.py b/sequence.py index 0563c23..6ef13b8 100644 --- a/sequence.py +++ b/sequence.py @@ -1,18 +1,10 @@ # 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 string import Template -import time - -from sql import Literal, For - from trytond.exceptions import UserError -from trytond.model.exceptions import ValidationError -from trytond.model import ModelView, ModelSQL, DeactivableMixin, fields, Check -from trytond.pyson import Eval, And -from trytond.transaction import Transaction -from trytond.pool import Pool, PoolMeta +from trytond.model import fields +from trytond.pyson import Eval +from trytond.pool import PoolMeta from trytond.i18n import gettext -from trytond import backend # Maps sequence_type.name → (prefix, padding) _NCF_TYPE_MAP = { @@ -38,6 +30,7 @@ _NCF_TYPE_MAP = { 'Exportación Electrónico': ('E46', 10), 'Pagos al Exterior Electrónico': ('E47', 10), } +NCF_PREFIXES = tuple(prefix for prefix, _padding in _NCF_TYPE_MAP.values()) class Sequence(metaclass=PoolMeta): @@ -114,8 +107,33 @@ class Sequence(metaclass=PoolMeta): @fields.depends('number_next', 'until_number') def on_change_with_remaining_qty(self, name=None): - if self.until_number and self.number_next: + if self.until_number is not None and self.number_next is not None: return self.until_number - self.number_next + 1 + + @classmethod + def validate_fields(cls, records, field_names): + super().validate_fields(records, field_names) + if field_names & { + 'prefix', 'padding', 'from_number', 'until_number', + 'number_next'}: + for record in records: + record._validate_ncf_range() + + def _validate_ncf_range(self): + if self.prefix not in NCF_PREFIXES: + return + expected_padding = 10 if self.prefix.startswith('E') else 8 + if self.padding != expected_padding: + raise UserError(gettext( + 'ncf_manager.msg_ncf_invalid_padding', + padding=expected_padding)) + if self.from_number is None or self.until_number is None: + return + if self.from_number < 1 or self.until_number < self.from_number: + raise UserError(gettext('ncf_manager.msg_ncf_invalid_range')) + if (self.number_next is not None + and self.number_next < self.from_number): + raise UserError(gettext('ncf_manager.msg_ncf_next_before_range')) class SequenceStrict(Sequence): "Sequence Strict" diff --git a/tests/test_module.py b/tests/test_module.py index 41060f8..e6831d8 100644 --- a/tests/test_module.py +++ b/tests/test_module.py @@ -50,7 +50,7 @@ class NcfManagerTestCase(ModuleTestCase): session_id, _start, _end = CreateChart.create() chart = CreateChart(session_id) chart.account.account_template = AccountTemplate(ModelData.get_id( - 'account_do', 'do_account_root')) + 'account_do', 'do_account_root_en')) chart.account.company = company chart.transition_create_account() @@ -228,8 +228,8 @@ class NcfManagerUnitTestCase(unittest.TestCase): self.assertNotIn('line.itbis_line', template) self.assertEqual(template.count('line.itbis_invoiced_line'), 2) - self.assertEqual(template.count('if test="invoice.tip_invoiced'), 2) - self.assertEqual(template.count('format_currency(invoice.tip_invoiced'), 2) + self.assertEqual(template.count('if test="invoice.tip'), 2) + self.assertEqual(template.count('format_currency(invoice.tip'), 2) def test_sequence_defaults_match_ncf_type(self): sequence = SimpleNamespace( @@ -247,6 +247,31 @@ 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_ncf_structure_and_normalization(self): + invoice = SimpleNamespace( + ncf_number=' b0100000001 ', ncf_type='1') + Invoice._ncf_validate_number(invoice) + self.assertEqual(invoice.ncf_number, 'B0100000001') + + invoice.ncf_number = 'E310000000001' + invoice.ncf_type = '31' + Invoice._ncf_validate_number(invoice) + + def test_ncf_rejects_invalid_structure_or_type(self): + invoice = SimpleNamespace(ncf_number='B010001', ncf_type='1') + with self.assertRaises(UserError): + Invoice._ncf_validate_number(invoice) + + def test_legacy_supplier_ncf_is_preserved_for_migration(self): + invoice = SimpleNamespace( + type='in', ncf_number='A010010010100000001', ncf_type='1') + Invoice._ncf_validate_number(invoice) + self.assertEqual(invoice.ncf_number, 'A010010010100000001') + + invoice.ncf_number = 'B0200000001' + with self.assertRaises(UserError): + Invoice._ncf_validate_number(invoice) + 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)