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)}