Mejora en el recálculo automático de los importes de la factura para muchos items.

This commit is contained in:
David Arranz 2025-11-09 12:24:02 +01:00
parent 6be0ad686c
commit 07e71e70a5

View File

@ -1,10 +1,10 @@
import { TaxCatalogProvider } from "@erp/core"; import { TaxCatalogProvider } from "@erp/core";
import React from "react"; import React from "react";
import { UseFormReturn } from "react-hook-form"; import { UseFormReturn, useWatch } from "react-hook-form";
import { import {
InvoiceItemCalcResult,
calculateInvoiceHeaderAmounts, calculateInvoiceHeaderAmounts,
calculateInvoiceItemAmounts, calculateInvoiceItemAmounts,
InvoiceItemCalcResult,
} from "../../domain"; } from "../../domain";
import { InvoiceFormData, InvoiceItemFormData } from "../../schemas"; import { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
@ -17,36 +17,39 @@ export type UseInvoiceAutoRecalcParams = {
* Hook que recalcula automáticamente los totales de cada línea * Hook que recalcula automáticamente los totales de cada línea
* y los totales generales de la factura cuando cambian los valores relevantes. * y los totales generales de la factura cuando cambian los valores relevantes.
* Adaptado a formulario con números planos (no DTOs). * Adaptado a formulario con números planos (no DTOs).
* Evita renders innecesarios (debounce + useDeferredValue).
*/ */
export function useInvoiceAutoRecalc( export function useInvoiceAutoRecalc(
form: UseFormReturn<InvoiceFormData>, form: UseFormReturn<InvoiceFormData>,
params: UseInvoiceAutoRecalcParams { currency_code, taxCatalog }: UseInvoiceAutoRecalcParams
) { ) {
const { const { setValue, trigger, control } = form;
watch,
trigger,
formState: { isDirty, isLoading, isSubmitting },
} = form;
const { currency_code, taxCatalog } = params; // Observa los ítems y el descuento global
const watchedItems = useWatch({ control, name: "items" }) ?? [];
const watchedDiscount = useWatch({ control, name: "discount_percentage" }) ?? 0;
// Cache de los totales de cada línea de factura // Diferir valores pesados para reducir renders (React 19)
const itemCache = React.useRef<Map<number, ReturnType<typeof calculateInvoiceItemAmounts>>>( const deferredItems = React.useDeferredValue(watchedItems);
new Map() const deferredDiscount = React.useDeferredValue(watchedDiscount);
);
// Cálculo de una línea (usa dominio puro) // Cache para evitar recálculos redundantes
const itemCache = React.useRef<Map<number, InvoiceItemCalcResult>>(new Map());
// Debounce para agrupar recalculados rápidos
const debounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Cálculo de una línea individual
const calculateItemTotals = React.useCallback( const calculateItemTotals = React.useCallback(
(item: InvoiceItemFormData, header_discount_percentage: number) => { (item: InvoiceItemFormData, header_discount_percentage: number) => {
const sanitizeString = (v?: number | string) => const sanitize = (v?: number | string) => (v && !Number.isNaN(Number(v)) ? String(v) : "0");
v && !Number.isNaN(Number(v)) ? String(v) : "0";
return calculateInvoiceItemAmounts( return calculateInvoiceItemAmounts(
{ {
quantity: sanitizeString(item.quantity), quantity: sanitize(item.quantity),
unit_amount: sanitizeString(item.unit_amount), unit_amount: sanitize(item.unit_amount),
discount_percentage: sanitizeString(item.discount_percentage), discount_percentage: sanitize(item.discount_percentage),
header_discount_percentage: sanitizeString(header_discount_percentage), header_discount_percentage: sanitize(header_discount_percentage),
tax_codes: item.tax_codes, tax_codes: item.tax_codes,
}, },
currency_code, currency_code,
@ -56,21 +59,21 @@ export function useInvoiceAutoRecalc(
[taxCatalog, currency_code] [taxCatalog, currency_code]
); );
// Totales globales (usa funciones del dominio) // Cálculo global de factura
const calculateInvoiceTotals = React.useCallback( const calculateInvoiceTotals = React.useCallback(
(items: InvoiceItemFormData[], header_discount_percentage: number) => { (items: InvoiceItemFormData[], header_discount_percentage: number) => {
const lines = items const lines = items
.filter((i) => !i.is_non_valued) .filter((i) => !i.is_non_valued)
.map((i) => { .map((i) => {
const itemTotals = calculateItemTotals(i, header_discount_percentage); const totals = calculateItemTotals(i, header_discount_percentage);
return { return {
subtotal_amount: itemTotals.subtotal_amount, subtotal_amount: totals.subtotal_amount,
discount_amount: itemTotals.discount_amount, discount_amount: totals.discount_amount,
header_discount_amount: itemTotals.header_discount_amount, header_discount_amount: totals.header_discount_amount,
taxable_amount: itemTotals.taxable_amount, taxable_amount: totals.taxable_amount,
taxes_amount: itemTotals.taxes_amount, taxes_amount: totals.taxes_amount,
total_amount: itemTotals.total_amount, total_amount: totals.total_amount,
taxes_summary: itemTotals.taxes_summary, taxes_summary: totals.taxes_summary,
}; };
}); });
@ -81,30 +84,29 @@ export function useInvoiceAutoRecalc(
// Observamos el formulario esperando cualquier cambio // Observamos el formulario esperando cualquier cambio
React.useEffect(() => { React.useEffect(() => {
if (!isDirty || isLoading || isSubmitting) return; if (!deferredItems.length) return;
const subscription = watch((formData, { name, type }) => { if (debounceTimer.current) clearTimeout(debounceTimer.current);
const items = (formData?.items || []) as InvoiceItemFormData[];
const header_discount_percentage = formData?.discount_percentage || 0;
if (items.length === 0) return; debounceTimer.current = setTimeout(() => {
let shouldUpdateHeader = false;
// Detectar cambios en la cabecera deferredItems.forEach((item, idx) => {
if (name === "discount_percentage") { const prev = itemCache.current.get(idx);
// Recalcular totales de factura const next = calculateItemTotals(item, deferredDiscount);
const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage);
// Estableer valores en cabecera if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) {
setInvoiceTotals(form, invoiceTotals); shouldUpdateHeader = true;
itemCache.current.set(idx, next);
setInvoiceItemTotals(form, idx, next);
}
});
// Forzar actualización de todas las líneas if (shouldUpdateHeader) {
items.forEach((item, idx) => { const totals = calculateInvoiceTotals(deferredItems, deferredDiscount);
const newTotals = calculateItemTotals(item, header_discount_percentage); setInvoiceTotals(form, totals);
itemCache.current.set(idx, newTotals);
setInvoiceItemTotals(form, idx, newTotals);
});
trigger([ void trigger([
"subtotal_amount", "subtotal_amount",
"discount_amount", "discount_amount",
"taxable_amount", "taxable_amount",
@ -112,127 +114,52 @@ export function useInvoiceAutoRecalc(
"total_amount", "total_amount",
]); ]);
} }
}, 100); // <-- debounce de 100ms, ajustable
// 2. Cambio puntual de una línea return () => {
if (name?.startsWith("items.") && type === "change") { if (debounceTimer.current) clearTimeout(debounceTimer.current);
const index = Number(name.split(".")[1]); };
const field = name.split(".")[2]; }, [deferredItems, deferredDiscount, calculateItemTotals, calculateInvoiceTotals, form, trigger]);
if (["quantity", "unit_amount", "discount_percentage", "tax_codes"].includes(field)) {
const item = items[index] as InvoiceItemFormData;
const prevTotals = itemCache.current.get(index);
const newTotals = calculateItemTotals(item, header_discount_percentage);
// Si no hay cambios en los totales, no tocamos nada
const itemHasChanges =
!prevTotals || JSON.stringify(prevTotals) !== JSON.stringify(newTotals);
if (!itemHasChanges) {
return;
}
// El total de esta línea ha cambiado => actualizamos la cache
itemCache.current.set(index, newTotals);
// Actualizar los campos de esta línea
setInvoiceItemTotals(form, index, newTotals);
// Recalcular totales de factura
const invoiceTotals = calculateInvoiceTotals(items, header_discount_percentage);
// Estableer valores en cabecera
setInvoiceTotals(form, invoiceTotals);
trigger([
"items",
"subtotal_amount",
"discount_amount",
"taxable_amount",
"taxes_amount",
"total_amount",
]);
}
}
});
return () => subscription.unsubscribe();
}, [
form,
watch,
trigger,
isDirty,
isLoading,
isSubmitting,
calculateItemTotals,
calculateInvoiceTotals,
]);
} }
// Ayudante para rellenar los importes de una línea // Ayudante para rellenar los importes de una línea
function setInvoiceItemTotals( function setInvoiceItemTotals(
form: UseFormReturn<InvoiceFormData>, form: UseFormReturn<InvoiceFormData>,
index: number, index: number,
newTotals: InvoiceItemCalcResult totals: InvoiceItemCalcResult
) { ) {
const { setValue } = form; const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
setValue(`items.${index}.subtotal_amount`, newTotals.subtotal_amount, { setValue(`items.${index}.subtotal_amount`, totals.subtotal_amount, opts);
shouldDirty: true, setValue(`items.${index}.discount_amount`, totals.discount_amount, opts);
shouldValidate: false, setValue(`items.${index}.taxable_amount`, totals.taxable_amount, opts);
}); setValue(`items.${index}.taxes_amount`, totals.taxes_amount, opts);
setValue(`items.${index}.discount_amount`, newTotals.discount_amount, { setValue(`items.${index}.total_amount`, totals.total_amount, opts);
shouldDirty: true,
shouldValidate: false,
});
setValue(`items.${index}.taxable_amount`, newTotals.taxable_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue(`items.${index}.taxes_amount`, newTotals.taxes_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue(`items.${index}.total_amount`, newTotals.total_amount, {
shouldDirty: true,
shouldValidate: false,
});
} }
// Ayudante para actualizar los importes de la cabecera // Ayudante para actualizar los importes de la cabecera
function setInvoiceTotals( function setInvoiceTotals(
form: UseFormReturn<InvoiceFormData>, form: UseFormReturn<InvoiceFormData>,
invoiceTotals: ReturnType<typeof calculateInvoiceHeaderAmounts> totals: ReturnType<typeof calculateInvoiceHeaderAmounts>
) { ) {
const { setValue } = form; const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
setValue("subtotal_amount", invoiceTotals.subtotal_amount, { setValue("subtotal_amount", totals.subtotal_amount, opts);
shouldDirty: true, setValue("discount_amount", totals.header_discount_amount, opts);
shouldValidate: false, setValue("taxable_amount", totals.taxable_amount, opts);
}); setValue("taxes_amount", totals.taxes_amount, opts);
setValue("discount_amount", invoiceTotals.header_discount_amount, { setValue("total_amount", totals.total_amount, opts);
shouldDirty: true,
shouldValidate: false,
});
setValue("taxable_amount", invoiceTotals.taxable_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue("taxes_amount", invoiceTotals.taxes_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue("total_amount", invoiceTotals.total_amount, {
shouldDirty: true,
shouldValidate: false,
});
setValue( setValue(
"taxes", "taxes",
invoiceTotals.taxes_summary.map((tax_item) => ({ totals.taxes_summary.map((t) => ({
tax_code: tax_item.code, tax_code: t.code,
tax_label: tax_item.name, tax_label: t.name,
taxable_amount: tax_item.taxable_amount, taxable_amount: t.taxable_amount,
taxes_amount: tax_item.taxes_amount, taxes_amount: t.taxes_amount,
})) })),
opts
); );
} }