fix(fin): el CFDI declaraba la descripción truncada del concepto

La partida guarda el mismo texto en dos columnas: `concept`, de 60 caracteres, que es la
que lee el PDF, y `description`, de 255, que es la que el CFDI prefiere
(`it.description or it.concept`). Al heredar de un concepto del catálogo solo se llenaba la
primera, así que el comprobante declaraba el texto cortado a 60 aunque el catálogo lo
tuviera entero: "Flete marítimo internacional puerta a puerta con seguro de c".

Ahora se heredan las dos, recortada y completa. Lo capturado a mano sigue mandando.

El PDF imprimía `concept — description`, que con las dos heredadas habría repetido el
texto —una vez cortado y otra entero—. `etiqueta_partida` lo resuelve por prefijo: cuando
description empieza con concept imprime solo la larga, y cuando son textos distintos
—una clave genérica más el detalle que alguien escribió— sigue imprimiendo los dos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 14:20:46 -05:00
parent 5a62c31d93
commit db4f3b1d53
3 changed files with 75 additions and 4 deletions

View File

@@ -86,8 +86,7 @@ def _build_lines(
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_amount") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
label = concept if not desc else f"{concept}{desc}"
label = label[:42].ljust(42)
label = etiqueta_partida(concept, desc)[:42].ljust(42)
row = f"{qty:>5.2f} {label} {unit:>12,.2f} {amount:>12,.2f}"
L.append((row, 10))
L.append(("-" * 78, 10))
@@ -117,6 +116,24 @@ def _build_lines(
return L
def etiqueta_partida(concept: str, description: str) -> str:
"""Cómo se lee la partida en el renglón del PDF.
Los dos campos vienen del mismo texto cuando la partida usa un concepto del catálogo:
``concept`` es la descripción recortada a 60 caracteres y ``description`` la completa.
Imprimir ambos repetiría el texto —una vez cortado y otra entero—, así que se detecta por
prefijo y se imprime sólo el largo.
Cuando son textos distintos —una clave genérica más el detalle que alguien escribió— se
imprimen los dos, que es lo que hacía siempre.
"""
if not description:
return concept
if not concept or description.startswith(concept):
return description
return f"{concept}{description}"
def _renglon_impuesto(grupo: dict, currency: str) -> str:
"""Un renglón del desglose de impuestos.

View File

@@ -643,8 +643,10 @@ def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
Hereda, siempre y sólo cuando el cliente no lo manda:
- ``concept``: el PDF de la factura sigue leyendo esa columna de texto libre, así
que ahí va la descripción del concepto (recortada al largo de la columna).
- ``concept`` y ``description``: la descripción del concepto va a las dos, recortada a 60 en
la primera —que es lo que el PDF lee y lo que la columna admite— y completa en la segunda,
que es la que el CFDI prefiere. Antes sólo se llenaba ``concept``, así que el comprobante
declaraba el texto truncado aunque el catálogo lo tuviera entero.
- Las claves fiscales (``product_service_id``, ``unit_of_measure_id``,
``tax_object_id``): sin ellas la partida capturada por catálogo quedaría
incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo,
@@ -671,6 +673,11 @@ def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
)
if not data.get("concept"):
data["concept"] = catalog_concept.description[:60]
if not data.get("description"):
# La descripción COMPLETA va al campo largo. El CFDI la prefiere sobre ``concept``,
# que está recortado a 60 caracteres, así que sin esto el comprobante declaraba un
# texto truncado de un concepto que el catálogo tiene entero.
data["description"] = catalog_concept.description[:255]
for field in _CONCEPT_INHERITED_FIELDS:
if data.get(field) is None:
data[field] = getattr(catalog_concept, field)

View File

@@ -695,3 +695,50 @@ def test_la_partida_hereda_el_impuesto_configurado_en_el_concepto(db):
# 1500 del precio del concepto × 8%
assert float(filas[0].amount) == 120.0
assert float(invoices_service.get_invoice(db, invoice.id, T, C).tax_amount) == 120.0
def test_la_partida_hereda_la_descripcion_completa_del_concepto(db):
"""``concept`` está recortado a 60; el CFDI declara ``description``, que va completa.
Sin esto el comprobante llevaba el texto truncado de un concepto que el catálogo tenía
entero.
"""
largo = "Flete marítimo internacional puerta a puerta con seguro de carga incluido y maniobras"
assert len(largo) > 60
payload = _concept_payload(db).model_dump()
payload["description"] = largo
concepto = concepts_service.create_concept(db, ConceptCreate(**payload), T, C, "tester")
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-DESC"), T, C)
item = invoices_service.create_item(
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id), T, C
)
assert item.concept == largo[:60]
assert item.description == largo, "el CFDI declararía el texto truncado"
def test_la_descripcion_capturada_manda_sobre_la_del_concepto(db):
concepto = _concepto_configurado(db)
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-DESC2"), T, C)
item = invoices_service.create_item(
db,
InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, description="Contenedor 40HC"),
T, C,
)
assert item.description == "Contenedor 40HC"
def test_la_etiqueta_del_pdf_no_repite_el_texto_del_concepto():
"""El renglón del PDF imprime un solo texto cuando los dos campos son el mismo."""
from api.v1.modules.fin.invoices.pdf import etiqueta_partida
largo = "Flete marítimo internacional puerta a puerta con seguro de carga incluido"
# Heredados del catálogo: concept es el recorte de description.
assert etiqueta_partida(largo[:60], largo) == largo
# Textos distintos: se imprimen los dos, como siempre.
assert etiqueta_partida("flete_internacional", "Contenedor 40HC") == "flete_internacional — Contenedor 40HC"
# Sin descripción, sólo el concepto.
assert etiqueta_partida("flete_internacional", "") == "flete_internacional"