From 5a62c31d93b5b5ee401afcc87d8c7c5444283a3a Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Tue, 11 Aug 2026 14:11:38 -0500 Subject: [PATCH] feat(fin): la partida hereda todo lo configurado en el concepto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Al elegir un concepto del catálogo, la partida ya heredaba sus claves del SAT, pero eso pasaba en silencio dentro del backend: en pantalla los tres selects se quedaban en "Selecciona…" aunque el concepto los tuviera configurados, y no había forma de ver qué impuesto iba a aplicar antes de guardar. Backend: - unit_amount se hereda del unit_price del concepto. Estaba prellenado SOLO por el formulario web, así que cualquier otro cliente de la API tenía que teclearlo. Requiere que unit_amount sea opcional en InvoiceItemCreate: con el default 0 de InvoiceItemBase siempre llegaba valor y el service no podía distinguir "no lo capturó" de "capturó 0". Un 0 explícito se respeta — una partida de cortesía es una decisión, no un campo vacío. Frontend: - Al elegir el concepto se copian precio y las tres claves del SAT a los campos, para que se vean antes de guardar. Elegir una partida genérica las limpia, en vez de arrastrar las del concepto anterior a algo que no las tiene. - Se muestra el impuesto que se aplicará, resuelto con la misma precedencia del backend: el del concepto si lo define, el % de la factura si no, y "no causa impuesto" cuando el objeto de impuesto no es 02. Lo que se cambie en el formulario sigue mandando sobre el catálogo. Co-Authored-By: Claude Opus 5 (1M context) --- backend/api/v1/modules/fin/invoices/dto.py | 3 + .../api/v1/modules/fin/invoices/service.py | 20 +++- backend/tests/test_fin_sat_catalogs.py | 96 +++++++++++++++++++ .../dashboard/fin/facturas/[id]/+page.svelte | 54 ++++++++++- 4 files changed, 170 insertions(+), 3 deletions(-) diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py index 40027d7..e353759 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -31,6 +31,9 @@ class InvoiceItemCreate(InvoiceItemBase): invoice_id: int # Opcional solo si viene concept_id: el service copia la descripción del concepto. concept: str | None = Field(None, max_length=60) + # Opcional para poder heredar el precio del concepto: con el default 0 de InvoiceItemBase + # siempre llegaría un valor y el service no podría distinguir "no lo capturó" de "capturó 0". + unit_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) class InvoiceItemUpdate(InvoiceItemSatFields): diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index a3798ac..248cd1a 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -572,6 +572,9 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem: # Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían. _CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id") +# Campos que la partida hereda del concepto con OTRO nombre: (campo de la partida, del concepto). +_CONCEPT_RENAMED_FIELDS = (("unit_amount", "unit_price"),) + # Datos de facturación que la factura hereda de la ficha del cliente: (campo de la factura, # campo del Account, catálogo del SAT contra el que se resuelve la clave). _ACCOUNT_INHERITED_BILLING = ( @@ -638,7 +641,7 @@ def _inherit_account_billing(db, data: dict, tenant_id, company_id) -> None: def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: """Completa la partida a partir del concepto del catálogo. - Hereda dos cosas cuando el cliente no las manda: + 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). @@ -646,6 +649,14 @@ def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: ``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, para poder facturar una partida con una unidad distinta a la del concepto. + - ``unit_amount`` desde el ``unit_price`` del concepto: si el catálogo ya tiene el precio, + volver a teclearlo en cada partida es trabajo doble y una fuente de discrepancias. Se + hereda en el service y no sólo en la pantalla, para que cualquier cliente de la API lo + obtenga igual — antes el precio lo prellenaba únicamente el formulario web. + + El impuesto NO se hereda aquí: vive en filas propias y lo resuelve + ``taxes_service.sync_item_taxes`` después del insert, que es quien sabe leer la + configuración fiscal del concepto. """ concept_id = data.get("concept_id") if concept_id is not None: @@ -663,6 +674,13 @@ def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None: for field in _CONCEPT_INHERITED_FIELDS: if data.get(field) is None: data[field] = getattr(catalog_concept, field) + for campo_partida, campo_concepto in _CONCEPT_RENAMED_FIELDS: + if data.get(campo_partida) is None: + data[campo_partida] = getattr(catalog_concept, campo_concepto) + # unit_amount es NOT NULL con server_default: un None que nadie llenó se retira para que + # mande el default de la columna, en vez de reventar en el flush. + if "unit_amount" in data and data["unit_amount"] is None: + data.pop("unit_amount") if not data.get("concept"): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py index d7b57c9..41859d7 100644 --- a/backend/tests/test_fin_sat_catalogs.py +++ b/backend/tests/test_fin_sat_catalogs.py @@ -599,3 +599,99 @@ def test_el_timbrado_rechaza_una_factura_descuadrada(db): with pytest.raises(CfdiBuildError) as exc: stamping_service._verifica_cuadre_con_la_factura(invoice, data) assert "no cuadran con el comprobante" in str(exc.value) + + +# ---------- La partida hereda del concepto ya configurado ---------- + +def _concepto_configurado(db, **fiscal): + """Concepto con precio, claves del SAT y —si se pide— su impuesto por defecto.""" + payload = _concept_payload(db) + datos = payload.model_dump() + datos.update(fiscal) + return concepts_service.create_concept(db, ConceptCreate(**datos), T, C, "tester") + + +def test_la_partida_hereda_el_precio_del_concepto(db): + """Si el catálogo ya trae el precio, no hay que volver a teclearlo en cada partida. + + Se hereda en el service y no sólo en el formulario web, para que cualquier cliente de la API + obtenga lo mismo. + """ + concepto = _concepto_configurado(db) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC1"), T, C) + + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, quantity=Decimal("2")), T, C + ) + + assert float(item.unit_amount) == 1500.0 + assert item.product_service_id == concepto.product_service_id + assert item.unit_of_measure_id == concepto.unit_of_measure_id + assert item.tax_object_id == concepto.tax_object_id + assert item.concept == "Flete marítimo internacional"[:60] + assert float(invoices_service.get_invoice(db, invoice.id, T, C).subtotal) == 3000.0 + + +def test_el_precio_capturado_manda_sobre_el_del_concepto(db): + concepto = _concepto_configurado(db) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC2"), T, C) + + item = invoices_service.create_item( + db, + InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, unit_amount=Decimal("999")), + T, C, + ) + + assert float(item.unit_amount) == 999.0 + + +def test_un_precio_cero_capturado_no_se_sustituye_por_el_del_concepto(db): + """Cero es una decisión válida —una partida de cortesía— y no un campo vacío.""" + concepto = _concepto_configurado(db) + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC3"), T, C) + + item = invoices_service.create_item( + db, + InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id, unit_amount=Decimal("0")), + T, C, + ) + + assert float(item.unit_amount) == 0.0 + + +def test_una_partida_generica_sin_precio_queda_en_cero(db): + """Sin concepto no hay de dónde heredar: manda el default de la columna.""" + invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-HC4"), T, C) + + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept="flete_internacional"), T, C + ) + + assert float(item.unit_amount) == 0.0 + + +def test_la_partida_hereda_el_impuesto_configurado_en_el_concepto(db): + """La terna fiscal del concepto llega a la partida como su fila de impuesto.""" + from api.v1.modules.fin.catalogs.models import Tax + from api.v1.modules.fin.invoices.models import InvoiceItemTax + + iva = db.query(Tax).filter(Tax.code == "002").one() + concepto = _concepto_configurado( + db, default_tax_id=iva.id, default_tax_factor="Tasa", default_tax_rate=Decimal("0.08") + ) + invoice = invoices_service.create_invoice( + db, InvoiceCreate(reference="F-HC5", tax_rate=Decimal("16")), T, C + ) + + item = invoices_service.create_item( + db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concepto.id), T, C + ) + + filas = db.query(InvoiceItemTax).filter( + InvoiceItemTax.invoice_item_id == item.id, InvoiceItemTax.deleted_at.is_(None) + ).all() + assert len(filas) == 1 + assert float(filas[0].rate) == 0.08, "el % de la factura pisó el impuesto del concepto" + # 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 diff --git a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte index fcace10..690b1a9 100644 --- a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte @@ -264,7 +264,13 @@ addingItem = true; } - /** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. */ + /** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. + * + * Al elegir un concepto ya configurado se copian sus valores a los campos de la partida, para + * que se VEAN antes de guardar. El backend hereda lo mismo si no se le manda, pero dejar los + * selects del SAT vacíos en pantalla hacía parecer que faltaban datos que el catálogo ya tenía + * —y no dejaba ver qué impuesto iba a aplicar—. Lo que se cambie aquí manda sobre el catálogo. + */ function applyConceptChoice() { if (conceptChoice.startsWith('cat:')) { const c = activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4))); @@ -273,12 +279,45 @@ newItem.concept_id = c.id; newItem.concept = undefined; if (c.unit_price !== null && c.unit_price !== undefined) newItem.unit_amount = Number(c.unit_price); + newItem.product_service_id = c.product_service_id; + newItem.unit_of_measure_id = c.unit_of_measure_id; + newItem.tax_object_id = c.tax_object_id; } else { newItem.concept_id = null; newItem.concept = conceptChoice.slice(4); + // Una partida genérica no tiene de dónde heredar: se limpia para no arrastrar las + // claves del concepto anterior a algo que no las tiene. + newItem.product_service_id = null; + newItem.unit_of_measure_id = null; + newItem.tax_object_id = null; } } + /** Concepto del catálogo elegido en el formulario de alta, si hay uno. */ + const conceptoElegido = $derived( + conceptChoice.startsWith('cat:') + ? (activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4))) ?? null) + : null + ); + + /** Qué impuesto va a llevar la partida, resuelto con la misma precedencia que el backend. */ + const impuestoQueAplica = $derived.by(() => { + const objeto = taxObjects.find((o) => o.id === newItem.tax_object_id); + if (objeto?.code !== '02') { + return objeto + ? `${objeto.code} — no causa impuesto, no se le cobra IVA` + : 'Sin objeto de impuesto: la partida no llevará IVA y no se podrá timbrar'; + } + const c = conceptoElegido; + if (c?.default_tax_id && c.default_tax_factor) { + const nombre = c.default_tax?.description ?? 'Impuesto'; + return c.default_tax_factor === 'Exento' + ? `${nombre} exento (del concepto)` + : `${nombre} ${(Number(c.default_tax_rate ?? 0) * 100).toFixed(2)}% (del concepto)`; + } + return `IVA ${Number(invoice?.tax_rate ?? 0).toFixed(2)}% (por defecto de la factura)`; + }); + /** Etiqueta de la partida: el concepto del catálogo si lo tiene, si no el texto libre. */ function itemConceptLabel(it: InvoiceItem): string { const c = it.concept_id ? concepts.find((x) => x.id === it.concept_id) : undefined; @@ -461,9 +500,20 @@ {#each taxObjects as o (o.id)}{/each} - Con «02 — Sí objeto de impuesto» el IVA se calcula solo, con el % de la factura. + Solo «02 — Sí objeto de impuesto» lleva desglose; con las demás claves la partida + no causa IVA. +

+ Impuesto que se aplicará: + {impuestoQueAplica} +

+ {#if conceptoElegido} +

+ Los valores vienen del concepto {conceptoElegido.code}; + cambiarlos aquí solo afecta a esta partida. +

+ {/if}
{/if}