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