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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user