From ce09e0d30a296888689e9cf34708329e6b0ae258 Mon Sep 17 00:00:00 2001 From: Jair Cedillo Date: Fri, 7 Aug 2026 17:24:50 -0500 Subject: [PATCH] =?UTF-8?q?feat(fin):=20partidas=20de=20factura=20capturad?= =?UTF-8?q?as=20desde=20el=20cat=C3=A1logo=20de=20conceptos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/tests/test_fin_sat_catalogs.py | 8 ++- frontend/src/lib/api/fin/index.ts | 16 ++++- .../dashboard/fin/facturas/[id]/+page.svelte | 68 +++++++++++++++++-- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/backend/tests/test_fin_sat_catalogs.py b/backend/tests/test_fin_sat_catalogs.py index 8c3e9d9..33201cf 100644 --- a/backend/tests/test_fin_sat_catalogs.py +++ b/backend/tests/test_fin_sat_catalogs.py @@ -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, diff --git a/frontend/src/lib/api/fin/index.ts b/frontend/src/lib/api/fin/index.ts index 052329e..0d2bbde 100644 --- a/frontend/src/lib/api/fin/index.ts +++ b/frontend/src/lib/api/fin/index.ts @@ -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> & { invoice_id: number; - concept: string; }; export interface Payment { diff --git a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte index 6d4bdbc..8e085ad 100644 --- a/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte @@ -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([]); let payments = $state([]); let accounts = $state([]); + /** Catálogo de conceptos de la empresa; se cargan todos para poder etiquetar + * partidas que apunten a un concepto ya inactivo. */ + let concepts = $state([]); let form = $state({}); let tab = $state('conceptos'); let loading = $state(false); @@ -29,6 +32,10 @@ let addingPay = $state(false); let newItem = $state({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }); let newPay = $state({ invoice_id: 0, amount: 0, method: 'transferencia' }); + /** Opción elegida en el selector de concepto: `cat:` del catálogo o `txt:` 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 @@
{#if addingItem}
- + @@ -222,7 +276,7 @@ {#each items as it (it.id)} - {labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}{it.description}{/if} + {itemConceptLabel(it)}{#if it.description}{it.description}{/if} {it.quantity} {formatMoney(it.unit_amount, invoice.currency)} {formatMoney(it.line_total, invoice.currency)}