Mejora en el recálculo automático de los importes de la factura para muchos items.
This commit is contained in:
parent
6be0ad686c
commit
07e71e70a5
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user