feat(fin): la partida hereda todo lo configurado en el concepto

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 14:11:38 -05:00
parent 1f8fdf2866
commit 5a62c31d93
4 changed files with 170 additions and 3 deletions

View File

@@ -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):

View File

@@ -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,

View File

@@ -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

View File

@@ -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)}<option value={o.id}>{o.code} {o.description}</option>{/each}
</select>
<span class="text-xs text-muted-foreground">
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.
</span>
</label>
<p class="text-xs sm:col-span-2">
<span class="text-muted-foreground">Impuesto que se aplicará:</span>
<span class="font-medium">{impuestoQueAplica}</span>
</p>
{#if conceptoElegido}
<p class="text-xs text-muted-foreground sm:col-span-2">
Los valores vienen del concepto <span class="font-mono">{conceptoElegido.code}</span>;
cambiarlos aquí solo afecta a esta partida.
</p>
{/if}
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingItem = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
</div>
{/if}