feat(fin): partidas de factura capturadas desde el catálogo de conceptos

El selector de concepto de la partida deja de ser una lista fija en el código y
se alimenta del catálogo de conceptos de la empresa: al elegir uno se manda
concept_id y el backend copia la descripción a la columna de texto libre que
consume el PDF. Si el concepto trae precio unitario, se precarga en la partida.

Las claves genéricas anteriores quedan en un segundo grupo del mismo selector,
marcadas como "sin clave del SAT", para no bloquear a las empresas que aún no
tienen catálogo; si está vacío se enlaza al alta de conceptos.

El listado de partidas etiqueta con la clave y descripción del catálogo cuando
la partida lo referencia, y cae al texto libre para las facturas anteriores.

Los tipos de Invoice e InvoiceItem se completan con las claves fiscales que el
backend ya devuelve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:24:50 -05:00
parent ae0664e987
commit ce09e0d30a
3 changed files with 83 additions and 9 deletions

View File

@@ -23,7 +23,7 @@ from api.v1.modules.fin.catalogs.seed_data import CATALOGS, sync_catalogs
from api.v1.modules.fin.concepts import service as concepts_service
from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate
from api.v1.modules.fin.invoices import service as invoices_service
from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate
from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, InvoiceItemResponse
from api.v1.modules.fin.issuer import service as issuer_service
from api.v1.modules.fin.issuer.dto import IssuerSettingsInput
from api.v1.modules.fin.issuer.models import IssuerSettings
@@ -279,6 +279,12 @@ def test_invoice_item_inherits_concept_description(db):
assert item.concept == concept.description # copiada del catálogo para el PDF
assert item.concept_id == concept.id
# La respuesta expone las claves fiscales: el frontend etiqueta la partida con ellas.
payload = InvoiceItemResponse.model_validate(item).model_dump()
assert payload["concept_id"] == concept.id
assert payload["concept"] == concept.description
assert {"product_service_id", "unit_of_measure_id", "tax_object_id"} <= payload.keys()
# Si el cliente sí manda el texto, se respeta tal cual.
explicit = invoices_service.create_item(
db,

View File

@@ -37,6 +37,11 @@ export interface Invoice {
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
// Claves fiscales del CFDI (catálogos SAT); nulas mientras no se capturen.
voucher_type_id: number | null;
payment_form_id: number | null;
payment_method_id: number | null;
expedition_zip_code: string | null;
tenant_id: number;
company_id: number;
created_at: string;
@@ -47,17 +52,26 @@ export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' |
export interface InvoiceItem {
id: number;
invoice_id: number;
/** Texto libre que consume el PDF; se hereda del catálogo cuando hay `concept_id`. */
concept: string;
description: string | null;
quantity: number;
unit_amount: number;
line_total: number;
// Claves fiscales de la partida (catálogo de conceptos y catálogos SAT).
concept_id: number | null;
product_service_id: number | null;
unit_of_measure_id: number | null;
tax_object_id: number | null;
tenant_id: number;
company_id: number;
}
/**
* `concept` es opcional cuando se envía `concept_id`: el backend copia ahí la
* descripción del concepto del catálogo. Sin ninguno de los dos responde 422.
*/
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
invoice_id: number;
concept: string;
};
export interface Payment {

View File

@@ -6,8 +6,8 @@
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
invoicesAPI, invoiceItemsAPI, paymentsAPI,
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI,
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
} from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
@@ -20,6 +20,9 @@
let items = $state<InvoiceItem[]>([]);
let payments = $state<Payment[]>([]);
let accounts = $state<Account[]>([]);
/** Catálogo de conceptos de la empresa; se cargan todos para poder etiquetar
* partidas que apunten a un concepto ya inactivo. */
let concepts = $state<Concept[]>([]);
let form = $state<InvoiceInput>({});
let tab = $state('conceptos');
let loading = $state(false);
@@ -29,6 +32,10 @@
let addingPay = $state(false);
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
/** Opción elegida en el selector de concepto: `cat:<id>` del catálogo o `txt:<clave>` genérica. */
let conceptChoice = $state('txt:flete_internacional');
const activeConcepts = $derived(concepts.filter((c) => c.is_active));
$effect(() => {
const cid = companyId;
@@ -40,8 +47,9 @@
async function load(cid: number, id: number) {
loading = true;
try {
[invoice, items, payments, accounts] = await Promise.all([
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid)
[invoice, items, payments, accounts, concepts] = await Promise.all([
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid),
accountsAPI.list(cid), conceptsAPI.list(cid)
]);
form = { ...invoice };
} catch (e) {
@@ -127,7 +135,35 @@
}
}
function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; }
function startItem() {
newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 };
// Si la empresa ya tiene catálogo, se arranca con su primer concepto.
conceptChoice = activeConcepts.length ? `cat:${activeConcepts[0].id}` : 'txt:flete_internacional';
applyConceptChoice();
addingItem = true;
}
/** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. */
function applyConceptChoice() {
if (conceptChoice.startsWith('cat:')) {
const c = activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4)));
if (!c) return;
// Solo se manda concept_id: el backend copia ahí la descripción del concepto.
newItem.concept_id = c.id;
newItem.concept = undefined;
if (c.unit_price !== null && c.unit_price !== undefined) newItem.unit_amount = Number(c.unit_price);
} else {
newItem.concept_id = null;
newItem.concept = conceptChoice.slice(4);
}
}
/** 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;
return c ? `${c.code}${c.description}` : labelOf(QUOTE_CONCEPTS, it.concept);
}
async function saveItem() {
if (!companyId) return;
try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); }
@@ -207,7 +243,25 @@
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startItem}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
{#if addingItem}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Concepto</span>
<select class={inputCls} bind:value={conceptChoice} onchange={applyConceptChoice}>
{#if activeConcepts.length > 0}
<optgroup label="Catálogo de conceptos">
{#each activeConcepts as c (c.id)}<option value={`cat:${c.id}`}>{c.code} — {c.description}</option>{/each}
</optgroup>
{/if}
<optgroup label="Conceptos genéricos (sin clave del SAT)">
{#each QUOTE_CONCEPTS as c (c.value)}<option value={`txt:${c.value}`}>{c.label}</option>{/each}
</optgroup>
</select>
{#if activeConcepts.length === 0}
<span class="text-xs text-muted-foreground">
El catálogo de conceptos está vacío.
<a class="underline" href="/dashboard/fin/conceptos">Darlos de alta</a> permite facturar con clave del SAT.
</span>
{/if}
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
@@ -222,7 +276,7 @@
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell class="font-medium">{itemConceptLabel(it)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>