Prepare NCF manager for production on Tryton 8.0
Publicar paquete PyPI / publish (push) Successful in 1m17s

This commit is contained in:
José Arturo García
2026-08-16 13:36:51 -04:00
parent e272bb5cfa
commit b96639f3e8
10 changed files with 204 additions and 192 deletions
+30 -160
View File
@@ -1,192 +1,62 @@
name: Publish Package name: Publicar paquete PyPI
on: on:
push: push:
branches: branches: ["8.0"]
- "**"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
packages: write
jobs: jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - 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 - name: Configurar Python
run: | uses: https://gitea.com/actions/setup-python@v5
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
with: with:
python-version: "3.11" python-version: "3.11"
- name: Install build tools - name: Instalar herramientas
if: env.PUBLISH_PACKAGE == 'true' run: python -m pip install --upgrade "build>=1,<2" "twine>=6,<7"
run: |
python -m pip install --upgrade pip
python -m pip install build twine
- name: Check publishing credentials - name: Comprobar credenciales
if: env.PUBLISH_PACKAGE == 'true'
env: env:
TWINE_USERNAME: ${{ secrets.REGISTRY_USER }}
TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: | run: test -n "$TWINE_PASSWORD" || { echo "Missing REGISTRY_PASSWORD secret"; exit 1; }
test -n "$TWINE_USERNAME" || { echo "Missing REGISTRY_USER secret"; exit 1; }
test -n "$TWINE_PASSWORD" || { echo "Missing REGISTRY_PASSWORD secret"; exit 1; }
- name: Set CI package version - name: Fijar versión CI
if: env.PUBLISH_PACKAGE == 'true'
env: env:
TWINE_USERNAME: ${{ secrets.REGISTRY_USER }} PACKAGE_VERSION: 8.0.${{ gitea.run_number }}
TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: | run: |
python - <<'PY' python - <<'PY'
from pathlib import Path from pathlib import Path
from urllib.error import HTTPError import os, re
from urllib.parse import urlencode path = Path('tryton.cfg')
from urllib.request import Request, urlopen text = path.read_text()
import base64 path.write_text(re.sub(r'(?m)^version=.*$',
import json f"version={os.environ['PACKAGE_VERSION']}", text))
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}')
PY PY
- name: Build package - name: Construir y validar
if: env.PUBLISH_PACKAGE == 'true'
run: | run: |
python -m build python -m build
- name: Check package
if: env.PUBLISH_PACKAGE == 'true'
run: |
python -m twine check dist/* python -m twine check dist/*
- name: Publish to Gitea PyPI registry - name: Publicar en Gitea PyPI
if: env.PUBLISH_PACKAGE == 'true'
env: env:
TWINE_USERNAME: ${{ secrets.REGISTRY_USER }} TWINE_USERNAME: unpaismejor
TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} TWINE_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: | run: |
python -m twine upload \ python -m twine upload --non-interactive \
--repository-url https://gitea.joseagrc.com/api/packages/tryton-do/pypi \ --repository-url https://code.unpaismejor.org.do/api/packages/tryton-do/pypi \
dist/* dist/*
+1 -1
View File
@@ -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 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 it under the terms of the GNU General Public License as published by
+6 -3
View File
@@ -12,7 +12,7 @@ Installing
Install the package and activate the module in the database:: Install the package and activate the module in the database::
pip install trytond_ncf_manager pip install unpaismejor_ncf_manager
trytond-admin -d <database> -u ncf_manager trytond-admin -d <database> -u ncf_manager
Publishing Publishing
@@ -21,7 +21,10 @@ Publishing
The included Gitea Actions workflow builds and publishes the package to the The included Gitea Actions workflow builds and publishes the package to the
Gitea PyPI registry from version-series branches such as ``8.0``. Gitea PyPI registry from version-series branches such as ``8.0``.
Configure these repository secrets: Configure this repository secret:
* ``REGISTRY_USER``
* ``REGISTRY_PASSWORD`` * ``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.
+4 -4
View File
@@ -2046,7 +2046,7 @@
</table:table-row> </table:table-row>
<table:table-row table:style-name="Table4.2"> <table:table-row table:style-name="Table4.2">
<table:table-cell table:style-name="Table4.A2" table:number-columns-spanned="2" office:value-type="string"> <table:table-cell table:style-name="Table4.A2" table:number-columns-spanned="2" office:value-type="string">
<text:p text:style-name="P45"><text:placeholder text:placeholder-type="text">&lt;if test=&quot;invoice.tip_invoiced &gt; 0&quot;&gt;</text:placeholder></text:p> <text:p text:style-name="P45"><text:placeholder text:placeholder-type="text">&lt;if test=&quot;invoice.tip &gt; 0&quot;&gt;</text:placeholder></text:p>
</table:table-cell> </table:table-cell>
<table:covered-table-cell/> <table:covered-table-cell/>
</table:table-row> </table:table-row>
@@ -2055,7 +2055,7 @@
<text:p text:style-name="P73">Propina:</text:p> <text:p text:style-name="P73">Propina:</text:p>
</table:table-cell> </table:table-cell>
<table:table-cell table:style-name="Table4.B12" office:value-type="string"> <table:table-cell table:style-name="Table4.B12" office:value-type="string">
<text:p text:style-name="P71"><text:placeholder text:placeholder-type="text">&lt;format_currency(invoice.tip_invoiced, invoice.party.lang, invoice.currency)&gt;</text:placeholder></text:p> <text:p text:style-name="P71"><text:placeholder text:placeholder-type="text">&lt;format_currency(invoice.tip, invoice.party.lang, invoice.currency)&gt;</text:placeholder></text:p>
</table:table-cell> </table:table-cell>
</table:table-row> </table:table-row>
<table:table-row table:style-name="Table4.1"> <table:table-row table:style-name="Table4.1">
@@ -2835,7 +2835,7 @@
</table:table-row> </table:table-row>
<table:table-row table:style-name="Table13.2"> <table:table-row table:style-name="Table13.2">
<table:table-cell table:style-name="Table13.A2" table:number-columns-spanned="2" office:value-type="string"> <table:table-cell table:style-name="Table13.A2" table:number-columns-spanned="2" office:value-type="string">
<text:p text:style-name="P45"><text:placeholder text:placeholder-type="text">&lt;if test=&quot;invoice.tip_invoiced &gt; 0&quot;&gt;</text:placeholder></text:p> <text:p text:style-name="P45"><text:placeholder text:placeholder-type="text">&lt;if test=&quot;invoice.tip &gt; 0&quot;&gt;</text:placeholder></text:p>
</table:table-cell> </table:table-cell>
<table:covered-table-cell/> <table:covered-table-cell/>
</table:table-row> </table:table-row>
@@ -2844,7 +2844,7 @@
<text:p text:style-name="P73">Propina:</text:p> <text:p text:style-name="P73">Propina:</text:p>
</table:table-cell> </table:table-cell>
<table:table-cell table:style-name="Table13.B12" office:value-type="string"> <table:table-cell table:style-name="Table13.B12" office:value-type="string">
<text:p text:style-name="P71"><text:placeholder text:placeholder-type="text">&lt;format_currency(invoice.tip_invoiced, invoice.party.lang, invoice.currency)&gt;</text:placeholder></text:p> <text:p text:style-name="P71"><text:placeholder text:placeholder-type="text">&lt;format_currency(invoice.tip, invoice.party.lang, invoice.currency)&gt;</text:placeholder></text:p>
</table:table-cell> </table:table-cell>
</table:table-row> </table:table-row>
<table:table-row table:style-name="Table13.1"> <table:table-row table:style-name="Table13.1">
+53 -2
View File
@@ -3,6 +3,7 @@
# this repository contains the full copyright notices and license terms. # this repository contains the full copyright notices and license terms.
from decimal import Decimal from decimal import Decimal
from datetime import date from datetime import date
import re
from sql.aggregate import Sum 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
@@ -1563,7 +1564,7 @@ class Invoice(metaclass=PoolMeta):
ncf_code = invoice.ncf_type ncf_code = invoice.ncf_type
if invoice.type == 'in' and ncf_code == '99': if ncf_code == '99':
continue continue
ncf_sequence = invoice.ncf_sequence or cls._ncf_find_active_sequence( ncf_sequence = invoice.ncf_sequence or cls._ncf_find_active_sequence(
@@ -1712,6 +1713,53 @@ 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_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 @staticmethod
def _ncf_is_fiscal_cancellation_required(invoice): def _ncf_is_fiscal_cancellation_required(invoice):
return ( return (
@@ -1782,6 +1830,8 @@ class Invoice(metaclass=PoolMeta):
cls.set_ncf_number(invoices) cls.set_ncf_number(invoices)
for invoice in invoices: for invoice in invoices:
cls._ncf_validate_number(invoice)
cls._ncf_check_duplicate(invoice)
ncf_code = invoice.ncf_type ncf_code = invoice.ncf_type
is_debit_note = cls._ncf_is_debit_note(invoice) is_debit_note = cls._ncf_is_debit_note(invoice)
is_credit_note = cls._ncf_is_credit_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' is_without_ncf = ncf_code == '99'
if invoice.type == 'out': if invoice.type == 'out':
invoice._ncf_validate_selected_sequence() if not is_without_ncf:
invoice._ncf_validate_selected_sequence()
if is_credit_note: if is_credit_note:
invoice.check_credit_note() invoice.check_credit_note()
invoice.check_ncf_number_modified() invoice.check_ncf_number_modified()
+21
View File
@@ -105,5 +105,26 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.message" id="msg_partial_credit_note_refund_not_allowed"> <record model="ir.message" id="msg_partial_credit_note_refund_not_allowed">
<field name="text">Partial credit note refund is not allowed.</field> <field name="text">Partial credit note refund is not allowed.</field>
</record> </record>
<record model="ir.message" id="msg_invalid_ncf_structure">
<field name="text">The fiscal number "%(number)s" must have the DGII B + 10 digits or E + 12 digits structure.</field>
</record>
<record model="ir.message" id="msg_invalid_ncf_type_prefix">
<field name="text">The fiscal number "%(number)s" does not match the expected prefix "%(prefix)s".</field>
</record>
<record model="ir.message" id="msg_ncf_sequence_company_mismatch">
<field name="text">The fiscal sequence and its authorization must belong to the same company.</field>
</record>
<record model="ir.message" id="msg_ncf_sequence_prefix_mismatch">
<field name="text">The fiscal sequence must use prefix "%(prefix)s" for the selected NCF type.</field>
</record>
<record model="ir.message" id="msg_ncf_invalid_padding">
<field name="text">The fiscal sequence must use a padding of %(padding)s digits.</field>
</record>
<record model="ir.message" id="msg_ncf_invalid_range">
<field name="text">The authorized fiscal range must start at 1 or greater and end at or after its start.</field>
</record>
<record model="ir.message" id="msg_ncf_next_before_range">
<field name="text">The next fiscal number cannot be before the authorized range.</field>
</record>
</data> </data>
</tryton> </tryton>
+25 -1
View File
@@ -1,8 +1,10 @@
# This file is part of ncf_manager module for Tryton. The COPYRIGHT file at the top level of # 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. # 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, fields
from trytond.pool import Pool from trytond.pool import Pool
from trytond.pyson import Eval, Id from trytond.pyson import Eval
from trytond.transaction import Transaction from trytond.transaction import Transaction
@@ -146,3 +148,25 @@ class NCFManagerSequences(ModelSQL, ModelView):
if not self.ncf_type_string: if not self.ncf_type_string:
return '' return ''
return self.ncf_type_string.split('-')[0] 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))
+6 -6
View File
@@ -1,13 +1,13 @@
[build-system] [build-system]
requires = ['hatchling >= 1', 'hatch-tryton'] requires = ['hatchling >=1,<1.31', 'hatch-tryton']
build-backend = 'hatchling.build' build-backend = 'hatchling.build'
[project] [project]
name = 'trytond_ncf_manager' name = 'unpaismejor_ncf_manager'
dynamic = ['version', 'dependencies', 'optional-dependencies', 'authors', 'readme'] dynamic = ['version', 'dependencies', 'optional-dependencies', 'authors', 'readme']
requires-python = '>=3.10' requires-python = '>=3.11'
maintainers = [ maintainers = [
{name = "Solutema"}, {name = "Fundación Un País Mejor"},
] ]
description = "NCF management for Tryton" description = "NCF management for Tryton"
license = 'GPL-3.0-or-later' license = 'GPL-3.0-or-later'
@@ -26,8 +26,8 @@ classifiers = [
ncf_manager = 'trytond.modules.ncf_manager' ncf_manager = 'trytond.modules.ncf_manager'
[project.urls] [project.urls]
homepage = "https://www.tryton.org/" homepage = "https://code.unpaismejor.org.do/tryton-do/ncf_manager"
repository = "https://gitea.joseagrc.com/tryton-do/ncf_manager" repository = "https://code.unpaismejor.org.do/tryton-do/ncf_manager.git"
[tool.hatch.build] [tool.hatch.build]
include = [ include = [
+30 -12
View File
@@ -1,18 +1,10 @@
# This file is part of ncf_manager module for Tryton. The COPYRIGHT file at the top level of # 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. # 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.exceptions import UserError
from trytond.model.exceptions import ValidationError from trytond.model import fields
from trytond.model import ModelView, ModelSQL, DeactivableMixin, fields, Check from trytond.pyson import Eval
from trytond.pyson import Eval, And from trytond.pool import PoolMeta
from trytond.transaction import Transaction
from trytond.pool import Pool, PoolMeta
from trytond.i18n import gettext from trytond.i18n import gettext
from trytond import backend
# Maps sequence_type.name → (prefix, padding) # Maps sequence_type.name → (prefix, padding)
_NCF_TYPE_MAP = { _NCF_TYPE_MAP = {
@@ -38,6 +30,7 @@ _NCF_TYPE_MAP = {
'Exportación Electrónico': ('E46', 10), 'Exportación Electrónico': ('E46', 10),
'Pagos al Exterior Electrónico': ('E47', 10), 'Pagos al Exterior Electrónico': ('E47', 10),
} }
NCF_PREFIXES = tuple(prefix for prefix, _padding in _NCF_TYPE_MAP.values())
class Sequence(metaclass=PoolMeta): class Sequence(metaclass=PoolMeta):
@@ -114,8 +107,33 @@ class Sequence(metaclass=PoolMeta):
@fields.depends('number_next', 'until_number') @fields.depends('number_next', 'until_number')
def on_change_with_remaining_qty(self, name=None): 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 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): class SequenceStrict(Sequence):
"Sequence Strict" "Sequence Strict"
+28 -3
View File
@@ -50,7 +50,7 @@ class NcfManagerTestCase(ModuleTestCase):
session_id, _start, _end = CreateChart.create() session_id, _start, _end = CreateChart.create()
chart = CreateChart(session_id) chart = CreateChart(session_id)
chart.account.account_template = AccountTemplate(ModelData.get_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.account.company = company
chart.transition_create_account() chart.transition_create_account()
@@ -228,8 +228,8 @@ class NcfManagerUnitTestCase(unittest.TestCase):
self.assertNotIn('line.itbis_line', template) self.assertNotIn('line.itbis_line', template)
self.assertEqual(template.count('line.itbis_invoiced_line'), 2) self.assertEqual(template.count('line.itbis_invoiced_line'), 2)
self.assertEqual(template.count('if test=&quot;invoice.tip_invoiced'), 2) self.assertEqual(template.count('if test=&quot;invoice.tip'), 2)
self.assertEqual(template.count('format_currency(invoice.tip_invoiced'), 2) self.assertEqual(template.count('format_currency(invoice.tip'), 2)
def test_sequence_defaults_match_ncf_type(self): def test_sequence_defaults_match_ncf_type(self):
sequence = SimpleNamespace( 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_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_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): def test_fiscal_cancellation_requires_ncf_number(self):
fiscal_invoice = SimpleNamespace(type='out', ncf_number='B0100000001') fiscal_invoice = SimpleNamespace(type='out', ncf_number='B0100000001')
non_fiscal_invoice = SimpleNamespace(type='out', ncf_number=None) non_fiscal_invoice = SimpleNamespace(type='out', ncf_number=None)