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:
@@ -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 import service as concepts_service
|
||||||
from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate
|
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 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 import service as issuer_service
|
||||||
from api.v1.modules.fin.issuer.dto import IssuerSettingsInput
|
from api.v1.modules.fin.issuer.dto import IssuerSettingsInput
|
||||||
from api.v1.modules.fin.issuer.models import IssuerSettings
|
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 == concept.description # copiada del catálogo para el PDF
|
||||||
assert item.concept_id == concept.id
|
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.
|
# Si el cliente sí manda el texto, se respeta tal cual.
|
||||||
explicit = invoices_service.create_item(
|
explicit = invoices_service.create_item(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ export interface Invoice {
|
|||||||
owner_user_id: string | null;
|
owner_user_id: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
updated_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;
|
tenant_id: number;
|
||||||
company_id: number;
|
company_id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -47,17 +52,26 @@ export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' |
|
|||||||
export interface InvoiceItem {
|
export interface InvoiceItem {
|
||||||
id: number;
|
id: number;
|
||||||
invoice_id: number;
|
invoice_id: number;
|
||||||
|
/** Texto libre que consume el PDF; se hereda del catálogo cuando hay `concept_id`. */
|
||||||
concept: string;
|
concept: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit_amount: number;
|
unit_amount: number;
|
||||||
line_total: 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;
|
tenant_id: number;
|
||||||
company_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'>> & {
|
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
|
||||||
invoice_id: number;
|
invoice_id: number;
|
||||||
concept: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { companyStore } from '$lib/stores/company.svelte';
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
import {
|
import {
|
||||||
invoicesAPI, invoiceItemsAPI, paymentsAPI,
|
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI,
|
||||||
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
||||||
} from '$lib/api/fin';
|
} from '$lib/api/fin';
|
||||||
import { accountsAPI, type Account } from '$lib/api/crm';
|
import { accountsAPI, type Account } from '$lib/api/crm';
|
||||||
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
|
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
let items = $state<InvoiceItem[]>([]);
|
let items = $state<InvoiceItem[]>([]);
|
||||||
let payments = $state<Payment[]>([]);
|
let payments = $state<Payment[]>([]);
|
||||||
let accounts = $state<Account[]>([]);
|
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 form = $state<InvoiceInput>({});
|
||||||
let tab = $state('conceptos');
|
let tab = $state('conceptos');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -29,6 +32,10 @@
|
|||||||
let addingPay = $state(false);
|
let addingPay = $state(false);
|
||||||
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
|
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' });
|
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(() => {
|
$effect(() => {
|
||||||
const cid = companyId;
|
const cid = companyId;
|
||||||
@@ -40,8 +47,9 @@
|
|||||||
async function load(cid: number, id: number) {
|
async function load(cid: number, id: number) {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
[invoice, items, payments, accounts] = await Promise.all([
|
[invoice, items, payments, accounts, concepts] = await Promise.all([
|
||||||
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid)
|
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid),
|
||||||
|
accountsAPI.list(cid), conceptsAPI.list(cid)
|
||||||
]);
|
]);
|
||||||
form = { ...invoice };
|
form = { ...invoice };
|
||||||
} catch (e) {
|
} 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() {
|
async function saveItem() {
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); }
|
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>
|
<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}
|
{#if addingItem}
|
||||||
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
<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">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">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>
|
<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>
|
<Table.Body>
|
||||||
{#each items as it (it.id)}
|
{#each items as it (it.id)}
|
||||||
<Table.Row>
|
<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">{it.quantity}</Table.Cell>
|
||||||
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</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>
|
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
|
||||||
|
|||||||
Reference in New Issue
Block a user