FactuGES_ERP/modules/customer-invoices/src/web/hooks/calcs/use-invoice-auto-recalc.ts

184 lines
6.1 KiB
TypeScript
Raw Normal View History

2025-10-12 18:36:33 +00:00
import { TaxCatalogProvider } from "@erp/core";
2025-10-13 17:41:21 +00:00
import React from "react";
import { UseFormReturn, useWatch } from "react-hook-form";
2025-10-12 18:36:33 +00:00
import {
calculateInvoiceHeaderAmounts,
calculateInvoiceItemAmounts,
InvoiceItemCalcResult,
2025-10-12 18:36:33 +00:00
} from "../../domain";
2025-10-12 10:43:06 +00:00
import { InvoiceFormData, InvoiceItemFormData } from "../../schemas";
2025-10-11 16:44:38 +00:00
2025-10-12 18:36:33 +00:00
export type UseInvoiceAutoRecalcParams = {
currency_code: string;
taxCatalog: TaxCatalogProvider;
2025-11-09 11:27:05 +00:00
debug?: boolean;
2025-10-12 18:36:33 +00:00
};
2025-10-11 16:44:38 +00:00
/**
* Hook que recalcula automáticamente los totales de cada línea
* y los totales generales de la factura cuando cambian los valores relevantes.
2025-10-12 18:36:33 +00:00
* Adaptado a formulario con números planos (no DTOs).
* Evita renders innecesarios (debounce + useDeferredValue).
2025-10-11 16:44:38 +00:00
*/
2025-10-12 18:36:33 +00:00
export function useInvoiceAutoRecalc(
form: UseFormReturn<InvoiceFormData>,
2025-11-09 11:27:05 +00:00
{ currency_code, taxCatalog, debug = false }: UseInvoiceAutoRecalcParams
2025-10-12 18:36:33 +00:00
) {
const { setValue, trigger, control } = form;
2025-10-11 16:44:38 +00:00
// Observa los ítems y el descuento global
const watchedItems = useWatch({ control, name: "items" }) ?? [];
const watchedDiscount = useWatch({ control, name: "discount_percentage" }) ?? 0;
2025-10-12 18:36:33 +00:00
// Diferir valores pesados para reducir renders (React 19)
const deferredItems = React.useDeferredValue(watchedItems);
const deferredDiscount = React.useDeferredValue(watchedDiscount);
// 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);
2025-10-11 16:44:38 +00:00
// Cálculo de una línea individual
2025-10-11 16:44:38 +00:00
const calculateItemTotals = React.useCallback(
2025-10-13 17:41:21 +00:00
(item: InvoiceItemFormData, header_discount_percentage: number) => {
const sanitize = (v?: number | string) => (v && !Number.isNaN(Number(v)) ? String(v) : "0");
2025-10-11 16:44:38 +00:00
2025-10-12 18:36:33 +00:00
return calculateInvoiceItemAmounts(
{
quantity: sanitize(item.quantity),
unit_amount: sanitize(item.unit_amount),
discount_percentage: sanitize(item.discount_percentage),
header_discount_percentage: sanitize(header_discount_percentage),
2025-10-12 18:36:33 +00:00
tax_codes: item.tax_codes,
},
currency_code,
taxCatalog
2025-10-11 16:44:38 +00:00
);
2025-10-12 18:36:33 +00:00
},
[taxCatalog, currency_code]
);
2025-10-11 16:44:38 +00:00
// Cálculo global de factura
2025-10-11 16:44:38 +00:00
const calculateInvoiceTotals = React.useCallback(
2025-10-13 17:41:21 +00:00
(items: InvoiceItemFormData[], header_discount_percentage: number) => {
2025-10-12 18:36:33 +00:00
const lines = items
.filter((i) => !i.is_non_valued)
.map((i) => {
const totals = calculateItemTotals(i, header_discount_percentage);
2025-10-12 18:36:33 +00:00
return {
subtotal_amount: totals.subtotal_amount,
discount_amount: totals.discount_amount,
header_discount_amount: totals.header_discount_amount,
taxable_amount: totals.taxable_amount,
taxes_amount: totals.taxes_amount,
total_amount: totals.total_amount,
taxes_summary: totals.taxes_summary,
2025-10-12 18:36:33 +00:00
};
});
2025-10-11 16:44:38 +00:00
2025-10-12 18:36:33 +00:00
return calculateInvoiceHeaderAmounts(lines, currency_code);
2025-10-11 16:44:38 +00:00
},
2025-10-12 18:36:33 +00:00
[calculateItemTotals, currency_code]
2025-10-11 16:44:38 +00:00
);
2025-10-13 17:41:21 +00:00
// Observamos el formulario esperando cualquier cambio
2025-10-11 16:44:38 +00:00
React.useEffect(() => {
if (!deferredItems.length) return;
2025-10-12 18:36:33 +00:00
if (debounceTimer.current) clearTimeout(debounceTimer.current);
2025-10-11 16:44:38 +00:00
debounceTimer.current = setTimeout(() => {
let shouldUpdateHeader = false;
2025-10-11 16:44:38 +00:00
deferredItems.forEach((item, idx) => {
const prev = itemCache.current.get(idx);
const next = calculateItemTotals(item, deferredDiscount);
2025-10-12 18:36:33 +00:00
const itemHasChanges =
!prev ||
prev.subtotal_amount !== next.subtotal_amount ||
prev.total_amount !== next.total_amount ||
prev.taxes_amount !== next.taxes_amount;
//if (!prev || JSON.stringify(prev) !== JSON.stringify(next)) { <-- Costoso y poco preciso
if (itemHasChanges) {
shouldUpdateHeader = true;
itemCache.current.set(idx, next);
setInvoiceItemTotals(form, idx, next);
2025-11-09 11:27:05 +00:00
if (debug) console.log(`💡 Recalc line ${idx + 1}`, next);
}
});
2025-10-12 18:36:33 +00:00
if (shouldUpdateHeader) {
const totals = calculateInvoiceTotals(deferredItems, deferredDiscount);
setInvoiceTotals(form, totals);
2025-11-09 11:27:05 +00:00
if (debug) console.log("📊 Recalc invoice totals", totals);
2025-10-13 17:41:21 +00:00
void trigger([
2025-10-12 18:36:33 +00:00
"subtotal_amount",
"discount_amount",
"taxable_amount",
"taxes_amount",
"total_amount",
]);
2025-10-11 16:44:38 +00:00
}
}, 100); // <-- debounce de 100ms, ajustable
2025-10-11 16:44:38 +00:00
return () => {
if (debounceTimer.current) clearTimeout(debounceTimer.current);
};
2025-11-09 11:27:05 +00:00
}, [
deferredItems,
deferredDiscount,
calculateItemTotals,
calculateInvoiceTotals,
form,
trigger,
debug,
]);
2025-10-11 16:44:38 +00:00
}
2025-10-12 18:36:33 +00:00
// Ayudante para rellenar los importes de una línea
function setInvoiceItemTotals(
form: UseFormReturn<InvoiceFormData>,
index: number,
totals: InvoiceItemCalcResult
2025-10-12 18:36:33 +00:00
) {
const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
2025-10-12 18:36:33 +00:00
setValue(`items.${index}.subtotal_amount`, totals.subtotal_amount, opts);
setValue(`items.${index}.discount_amount`, totals.discount_amount, opts);
setValue(`items.${index}.taxable_amount`, totals.taxable_amount, opts);
setValue(`items.${index}.taxes_amount`, totals.taxes_amount, opts);
setValue(`items.${index}.total_amount`, totals.total_amount, opts);
2025-10-12 18:36:33 +00:00
}
// Ayudante para actualizar los importes de la cabecera
function setInvoiceTotals(
form: UseFormReturn<InvoiceFormData>,
totals: ReturnType<typeof calculateInvoiceHeaderAmounts>
2025-10-12 18:36:33 +00:00
) {
const { setValue } = form;
const opts = { shouldDirty: true, shouldValidate: false } as const;
2025-10-12 18:36:33 +00:00
setValue("subtotal_amount", totals.subtotal_amount, opts);
setValue("discount_amount", totals.header_discount_amount, opts);
setValue("taxable_amount", totals.taxable_amount, opts);
setValue("taxes_amount", totals.taxes_amount, opts);
setValue("total_amount", totals.total_amount, opts);
2025-10-13 17:41:21 +00:00
setValue(
"taxes",
totals.taxes_summary.map((t) => ({
tax_code: t.code,
tax_label: t.name,
taxable_amount: t.taxable_amount,
taxes_amount: t.taxes_amount,
})),
opts
2025-10-13 17:41:21 +00:00
);
2025-10-12 18:36:33 +00:00
}