This commit is contained in:
David Arranz 2026-07-29 10:16:34 +02:00
parent 5ed6556036
commit 659df116a2
7 changed files with 211 additions and 8 deletions

View File

@ -0,0 +1,134 @@
# Proforma Create Contract
## Objetivo
Definir la semantica correcta de `create proforma` como creacion de cabecera inicial y separarla del calculo fiscal real que depende de lineas valoradas.
## Regla principal
`tax_regime_code` no es un impuesto aplicado.
En create:
- `taxRegimeCode` representa configuracion fiscal inicial
- `taxDefaults` sigue siendo un concepto de UI o resolucion interna, no un desglose fiscal persistido
- `taxes` representa impuestos realmente calculados desde lineas valoradas
- `totals` representa importes realmente calculados desde lineas valoradas
## Contrato funcional de create
Una proforma puede crearse sin lineas.
Si `items` se omite o se envia como `[]`:
- la proforma debe crearse correctamente como borrador
- debe generarse `proforma_reference`
- pueden resolverse defaults comerciales y fiscales iniciales
- `items` debe devolverse como `[]`
- `taxes` debe devolverse como `[]`
- los totales monetarios deben devolverse a `0`
- no deben crearse filas artificiales en `proforma_taxes`
## Payload minimo esperado
```ts
{
proforma_date: string;
customer_id: string;
proforma_series_code?: string | null;
target_invoice_series_code?: string | null;
currency_code?: string;
payment_method_id?: string | null;
payment_term_id?: string | null;
tax_regime_code?: string | null;
items?: [];
}
```
Notas:
- el contrato actual del modulo puede seguir exigiendo otros campos tecnicos ya existentes
- `items` es opcional; si existe, puede ir vacio
- no se usan defaults ocultos en Zod para disfrazar la intencion del request
## Response esperada al crear sin lineas
Adaptando nombres exactos al contrato publico actual:
```ts
{
id: string;
proforma_reference: string;
proforma_date: string;
customer_id: string;
currency_code: string;
payment_method_id: string | null;
payment_term_id: string | null;
tax_regime_code: string | null;
target_invoice_series_code: string | null;
items: [];
taxes: [];
totals: {
subtotal_before_discounts: "0.00";
line_discount_total: "0.00";
taxable_base: "0.00";
tax_total: "0.00";
total: "0.00";
};
}
```
## Punto correcto de calculo
- `create` crea cabecera minima, numera y resuelve defaults
- `update` permite anadir o editar lineas
- al existir lineas valoradas, `update` recalcula `taxes` y `totals`
## Wording de UI
La UI de create debe hablar de:
- "Se preparara automaticamente"
- "valores iniciales"
- "configuracion fiscal inicial"
La UI no debe sugerir:
- que los impuestos ya quedaron aplicados al crear
- que existe desglose fiscal real sin lineas
- que los totales ya fueron calculados si todavia no hay lineas valoradas
## Diagnostico SQL recomendado
```sql
SELECT
p.id,
p.proforma_reference,
COUNT(DISTINCT pi.item_id) AS item_rows,
COUNT(DISTINCT pt.tax_id) AS tax_rows
FROM proformas p
LEFT JOIN proforma_items pi
ON pi.proforma_id = p.id
LEFT JOIN proforma_taxes pt
ON pt.proforma_id = p.id
GROUP BY p.id, p.proforma_reference
HAVING COUNT(DISTINCT pi.item_id) = 0
AND COUNT(DISTINCT pt.tax_id) > 0;
```
Resultado esperado:
- `0` filas
Si devuelve filas:
- documentar los ids afectados
- revisar si provienen de datos legacy o de una version intermedia del flujo
- no borrar datos sin confirmacion explicita
## Pendientes conocidos
- `payment_term_id` aparece ya en contratos de transporte, pero el dominio/snapshot de proformas sigue sin soporte funcional completo en esta fase
- no se introduce un motor fiscal nuevo en este ajuste

View File

@ -24,6 +24,10 @@ En proformas:
- se pide cliente
- `proforma_series_code` es opcional; si falta, se usa la serie default activa de `document_type = proforma`
- `target_invoice_series_code` es opcional; si falta, se persiste `NULL`
- la creacion de proforma genera una cabecera valida aunque no existan lineas
- `items` puede omitirse o enviarse como `[]`
- si no hay lineas valoradas, la response debe devolver `items = []`, `taxes = []` y totales a `0`
- `tax_regime_code` en create representa configuracion fiscal inicial, no impuestos ya aplicados
- la UI obtiene ambas listas desde `GET /document-series`, filtrando por `document_type`
### Editar draft
@ -54,12 +58,16 @@ En proformas:
- el backend emite `target_invoice_series_code` como nombre preferente
- `series` puede mantenerse solo como alias legacy de salida mientras existan consumidores antiguos
- `taxes` representa impuestos calculados a partir de lineas valoradas
- `totals` representa importes calculados a partir de lineas valoradas
- create no debe inventar filas fiscales sobre base `0`
## Regla de emision
- si `target_invoice_series_code` tiene valor, la emision usa ese `seriesCode` para `document_type = issued_invoice`
- si `target_invoice_series_code` es `NULL`, `document-series` resuelve la serie default activa de `issued_invoice`
- la UI nunca debe consumir numeracion directa desde `POST /document-series/assign-next`
- los impuestos y totales reales se recalculan cuando la proforma ya tiene lineas en update
## Validacion defensiva

View File

@ -146,6 +146,16 @@ Preparar el esquema fisico para separar:
- `target_invoice_series_code` sigue siendo el unico concepto de serie futura de factura emitida
- la UI nueva de proformas ya envia `proforma_series_code` y `target_invoice_series_code`
## Estado Fase 2F
- create de proforma se consolida como creacion de cabecera/documento inicial
- una proforma sin lineas es valida y debe persistirse con `items = []`
- `tax_regime_code` queda como configuracion fiscal inicial de la cabecera
- `proforma_taxes` solo representa impuestos calculados, no defaults fiscales ni placeholders sobre base `0`
- si una proforma nace sin lineas, la response debe devolver `taxes = []` y totales monetarios a `0`
- update sigue siendo el punto donde se anaden lineas y se materializan impuestos/totales reales
- la UI deja de prometer que la fiscalidad queda "aplicada" en create y pasa a hablar de valores iniciales
## Rollback recomendado
- no aplicar nada en produccion en esta fase
@ -809,6 +819,28 @@ SELECT SUM(total_amount_value) FROM customer_invoices WHERE is_proforma = 0;
SELECT SUM(total_amount_value) FROM issued_invoices;
```
### Proformas sin lineas con impuestos materializados
```sql
SELECT
p.id,
p.proforma_reference,
COUNT(DISTINCT pi.item_id) AS item_rows,
COUNT(DISTINCT pt.tax_id) AS tax_rows
FROM proformas p
LEFT JOIN proforma_items pi
ON pi.proforma_id = p.id
LEFT JOIN proforma_taxes pt
ON pt.proforma_id = p.id
GROUP BY p.id, p.proforma_reference
HAVING COUNT(DISTINCT pi.item_id) = 0
AND COUNT(DISTINCT pt.tax_id) > 0;
```
Resultado esperado:
- `0` filas
## No incluido en fase 1A
- no cutover del backend

View File

@ -59,6 +59,9 @@ En V1:
- `proformas.proforma_reference` guarda la referencia visible de la proforma
- `proforma_series_code` es el input funcional de create para elegir la serie de `document_type = proforma`
- `proformas.target_invoice_series_code` es opcional y, cuando existe, apunta a una serie de `issued_invoice`, nunca a una serie de `proforma`
- create de proforma solo usa `document-series` para numerar la cabecera inicial; no calcula impuestos reales por si mismo
- una proforma puede crearse sin lineas y seguir siendo valida como borrador inicial
- si no hay lineas valoradas, `proforma_taxes` debe quedar vacia y los totales monetarios a `0`
- si `proformas.target_invoice_series_code` es `NULL`, la emision resuelve la serie default activa de `issued_invoice`
- en Fase 2E, `series` deja de ser el contrato nuevo para create/update de proformas
- la UI consulta `GET /document-series` con filtro por `document_type` e idealmente `is_active = true`

View File

@ -162,7 +162,9 @@ export class ProformaCreator implements IProformaCreator {
return Result.fail(paymentMethodResult.error);
}
// Items
// En create una proforma puede nacer sin líneas: en ese caso solo persistimos
// cabecera + defaults iniciales. Los impuestos y totales materializados salen
// de `items`, así que con `items = []` no se generan filas fiscales.
const resolvedItems: IProformaItemCreateProps[] = [];
for (const item of props.items) {

View File

@ -1,4 +1,10 @@
import { AlertCircleIcon, BanknoteIcon, CircleDollarSignIcon, ReceiptTextIcon } from "lucide-react";
import {
AlertCircleIcon,
BanknoteIcon,
CheckCircle2Icon,
CircleDollarSignIcon,
ReceiptTextIcon,
} from "lucide-react";
export const ProformaCreateAutoAppliedInfo = () => {
return (
@ -14,7 +20,14 @@ export const ProformaCreateAutoAppliedInfo = () => {
</p>
</div>
<div className="grid gap-4 border-b border-blue-200/80 py-4 md:grid-cols-3">
<div className="rounded-lg border border-blue-200/80 bg-white/60 p-4">
<p className="text-sm font-medium text-slate-900">Datos que se guardarán al crear</p>
<p className="mt-1 text-sm text-slate-600">
Fecha, cliente y serie de proforma para numerar el documento inicial.
</p>
</div>
<div className="grid gap-4 border-y border-blue-200/80 py-4 md:grid-cols-3">
<div className="flex items-start gap-3">
<CircleDollarSignIcon className="mt-0.5 size-5 text-blue-600" />
<div>
@ -43,10 +56,21 @@ export const ProformaCreateAutoAppliedInfo = () => {
<div className="flex items-start gap-3 pt-1">
<AlertCircleIcon className="mt-0.5 size-5 text-blue-600" />
<div>
<p className="font-medium">Fiscalidad inicial</p>
<p className="font-medium">Configuración fiscal inicial</p>
<p className="text-sm text-slate-600">
Se tomará del cliente o de la configuración de empresa. Los impuestos no se
calcularán hasta que añadas líneas a la proforma.
Se tomará del cliente o de la configuración de empresa. Los impuestos y totales se
calcularán cuando añadas líneas a la proforma.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-lg bg-white/60 p-4">
<CheckCircle2Icon className="mt-0.5 size-5 text-blue-600" />
<div>
<p className="font-medium">Cálculo pendiente</p>
<p className="text-sm text-slate-600">
Al crear sin líneas se guarda la cabecera. El desglose fiscal real se materializa
en la edición, cuando existan líneas valoradas.
</p>
</div>
</div>

View File

@ -42,11 +42,11 @@ export const ProformaUpdateTaxEditor = ({
className={className}
description={t(
"form_groups.proformas.taxes.description",
"Configuración fiscal de la proforma"
"Configura los valores fiscales por defecto de la proforma. Los impuestos aplicados y los totales se calculan desde las líneas valoradas."
)}
disabled={disabled}
icon={<LandmarkIcon className="size-5" />}
title={t("form_groups.proformas.taxes.title", "Impuestos y retenciones")}
title={t("form_groups.proformas.taxes.title", "Configuración fiscal")}
>
<FormSectionGrid>
<SelectField