179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
|
|
import { areMoneyDTOEqual } from "@erp/core";
|
|||
|
|
import { useMoney, usePercentage, useQuantity } from "@erp/core/hooks";
|
|||
|
|
import * as React from "react";
|
|||
|
|
import { UseFormReturn } from "react-hook-form";
|
|||
|
|
import { CustomerInvoiceFormData, CustomerInvoiceItemFormData } from "../../schemas";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Hook que recalcula automáticamente los totales de cada línea
|
|||
|
|
* y los totales generales de la factura cuando cambian los valores relevantes.
|
|||
|
|
*/
|
|||
|
|
export function useInvoiceAutoRecalc(form: UseFormReturn<CustomerInvoiceFormData>) {
|
|||
|
|
const {
|
|||
|
|
watch,
|
|||
|
|
setValue,
|
|||
|
|
getValues,
|
|||
|
|
formState: { isDirty, isLoading, isSubmitting },
|
|||
|
|
} = form;
|
|||
|
|
|
|||
|
|
const moneyHelper = useMoney();
|
|||
|
|
const qtyHelper = useQuantity();
|
|||
|
|
const pctHelper = usePercentage();
|
|||
|
|
|
|||
|
|
// Cálculo de una línea
|
|||
|
|
const calculateItemTotals = React.useCallback(
|
|||
|
|
(item: CustomerInvoiceItemFormData) => {
|
|||
|
|
if (!item) {
|
|||
|
|
const zero = moneyHelper.fromNumber(0);
|
|||
|
|
return {
|
|||
|
|
subtotalDTO: zero,
|
|||
|
|
discountAmountDTO: zero,
|
|||
|
|
taxableBaseDTO: zero,
|
|||
|
|
taxesDTO: zero,
|
|||
|
|
totalDTO: zero,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Subtotal = unit_amount × quantity
|
|||
|
|
const subtotalDTO = moneyHelper.multiply(item.unit_amount, qtyHelper.toNumber(item.quantity));
|
|||
|
|
|
|||
|
|
// Descuento = subtotal × (discount_percentage / 100)
|
|||
|
|
const discountDTO = moneyHelper.percentage(
|
|||
|
|
subtotalDTO,
|
|||
|
|
pctHelper.toNumber(item.discount_percentage)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Base imponible = subtotal − descuento
|
|||
|
|
const taxableBaseDTO = moneyHelper.sub(subtotalDTO, discountDTO);
|
|||
|
|
|
|||
|
|
// Impuestos (placeholder: se integrará con tax catalog)
|
|||
|
|
const taxesDTO = moneyHelper.fromNumber(0);
|
|||
|
|
|
|||
|
|
// Total = base imponible + impuestos
|
|||
|
|
const totalDTO = moneyHelper.add(taxableBaseDTO, taxesDTO);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
subtotalDTO,
|
|||
|
|
discountAmountDTO: discountDTO,
|
|||
|
|
taxableBaseDTO,
|
|||
|
|
taxesDTO,
|
|||
|
|
totalDTO,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
[moneyHelper, qtyHelper, pctHelper]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Cálculo de los totales de la factura a partir de los conceptos
|
|||
|
|
const calculateInvoiceTotals = React.useCallback(
|
|||
|
|
(items: CustomerInvoiceItemFormData[]) => {
|
|||
|
|
let subtotalDTO = moneyHelper.fromNumber(0);
|
|||
|
|
let discountTotalDTO = moneyHelper.fromNumber(0);
|
|||
|
|
let taxableBaseDTO = moneyHelper.fromNumber(0);
|
|||
|
|
let taxesDTO = moneyHelper.fromNumber(0);
|
|||
|
|
let totalDTO = moneyHelper.fromNumber(0);
|
|||
|
|
|
|||
|
|
for (const item of items) {
|
|||
|
|
const t = calculateItemTotals(item);
|
|||
|
|
subtotalDTO = moneyHelper.add(subtotalDTO, t.subtotalDTO);
|
|||
|
|
discountTotalDTO = moneyHelper.add(discountTotalDTO, t.discountAmountDTO);
|
|||
|
|
taxableBaseDTO = moneyHelper.add(taxableBaseDTO, t.taxableBaseDTO);
|
|||
|
|
taxesDTO = moneyHelper.add(taxesDTO, t.taxesDTO);
|
|||
|
|
totalDTO = moneyHelper.add(totalDTO, t.totalDTO);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
subtotalDTO,
|
|||
|
|
discountTotalDTO,
|
|||
|
|
taxableBaseDTO,
|
|||
|
|
taxesDTO,
|
|||
|
|
totalDTO,
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
[moneyHelper, calculateItemTotals]
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Suscribirse a cambios del formulario
|
|||
|
|
React.useEffect(() => {
|
|||
|
|
if (!isDirty || isLoading || isSubmitting) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const subscription = watch((formData, { name, type }) => {
|
|||
|
|
if (!formData?.items?.length) return;
|
|||
|
|
|
|||
|
|
// 1. Si cambia una línea completa (add/remove/move)
|
|||
|
|
if (name === "items" && type === "change") {
|
|||
|
|
formData.items.forEach((item, i) => {
|
|||
|
|
if (!item) return;
|
|||
|
|
|
|||
|
|
const typedItem = item as CustomerInvoiceItemFormData;
|
|||
|
|
const totals = calculateItemTotals(typedItem);
|
|||
|
|
const current = getValues(`items.${i}.total_amount`);
|
|||
|
|
|
|||
|
|
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
|
|||
|
|
setValue(`items.${i}.total_amount`, totals.totalDTO, {
|
|||
|
|
shouldDirty: true,
|
|||
|
|
shouldValidate: false,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Recalcular importes totales de la factura y
|
|||
|
|
// actualizar valores calculados.
|
|||
|
|
const typedItems = formData.items as CustomerInvoiceItemFormData[];
|
|||
|
|
const totalsGlobal = calculateInvoiceTotals(typedItems);
|
|||
|
|
|
|||
|
|
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
|
|||
|
|
setValue("discount_amount", totalsGlobal.discountTotalDTO);
|
|||
|
|
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
|
|||
|
|
setValue("taxes_amount", totalsGlobal.taxesDTO);
|
|||
|
|
setValue("total_amount", totalsGlobal.totalDTO);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Si cambia un campo dentro de un concepto
|
|||
|
|
if (name?.startsWith("items.") && type === "change") {
|
|||
|
|
const index = Number(name.split(".")[1]);
|
|||
|
|
const fieldName = name.split(".")[2];
|
|||
|
|
|
|||
|
|
if (["quantity", "unit_amount", "discount_percentage"].includes(fieldName)) {
|
|||
|
|
const typedItem = formData.items[index] as CustomerInvoiceItemFormData;
|
|||
|
|
if (!typedItem) return;
|
|||
|
|
|
|||
|
|
// Recalcular línea
|
|||
|
|
const totals = calculateItemTotals(typedItem);
|
|||
|
|
const current = getValues(`items.${index}.total_amount`);
|
|||
|
|
|
|||
|
|
if (!areMoneyDTOEqual(current, totals.totalDTO)) {
|
|||
|
|
setValue(`items.${index}.total_amount`, totals.totalDTO, {
|
|||
|
|
shouldDirty: true,
|
|||
|
|
shouldValidate: false,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Recalcular importes totales de la factura y
|
|||
|
|
// actualizar valores calculados.
|
|||
|
|
const typedItems = formData.items as CustomerInvoiceItemFormData[];
|
|||
|
|
const totalsGlobal = calculateInvoiceTotals(typedItems);
|
|||
|
|
|
|||
|
|
setValue("subtotal_amount", totalsGlobal.subtotalDTO);
|
|||
|
|
setValue("discount_amount", totalsGlobal.discountTotalDTO);
|
|||
|
|
setValue("taxable_amount", totalsGlobal.taxableBaseDTO);
|
|||
|
|
setValue("taxes_amount", totalsGlobal.taxesDTO);
|
|||
|
|
setValue("total_amount", totalsGlobal.totalDTO);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return () => subscription.unsubscribe();
|
|||
|
|
}, [
|
|||
|
|
watch,
|
|||
|
|
isDirty,
|
|||
|
|
isLoading,
|
|||
|
|
isSubmitting,
|
|||
|
|
setValue,
|
|||
|
|
getValues,
|
|||
|
|
calculateItemTotals,
|
|||
|
|
calculateInvoiceTotals,
|
|||
|
|
]);
|
|||
|
|
}
|