Compare commits

..

No commits in common. "588aacd48aaba4dc872795899e68606a3f7953eb" and "ff6905b845616d8c9cdd3abe8036002847c34ba9" have entirely different histories.

23 changed files with 270 additions and 313 deletions

View File

@ -1,5 +1,5 @@
const toSafeNumber = (value: string | number | null | undefined): number => { const toSafeNumber = (value: number | null | undefined): number => {
return Number(value ?? 0); return value ?? 0;
}; };
export const NumberHelper = { export const NumberHelper = {

View File

@ -1,54 +1,28 @@
type PickFormDirtyValuesOptions<T extends Record<string, any>> = {
replaceTopLevelArrayKeys?: Array<keyof T>;
};
/** /**
* Extrae solo los valores marcados como "dirty" por react-hook-form, * Extrae solo los valores marcados como "dirty" por react-hook-form,
* respetando la estructura anidada de dirtyFields. * respetando la estructura anidada de dirtyFields.
*
* Regla especial opcional: Si una key de primer nivel
* está configurada en `replaceTopLevelArrayKeys` y tiene
* cualquier dirty anidado, se incluye el array completo
* en lugar de intentar hacer patch recursivo
*/ */
export function pickFormDirtyValues<T extends Record<string, any>>( export function pickFormDirtyValues<T extends Record<string, any>>(
values: T, values: T,
dirtyFields: Partial<Record<keyof T, any>>, dirtyFields: Partial<Record<keyof T, any>>
options?: PickFormDirtyValuesOptions<T>
): Partial<T> { ): Partial<T> {
const result: Partial<T> = {}; const result: Partial<T> = {};
const replaceTopLevelArrayKeys = options?.replaceTopLevelArrayKeys ?? [];
for (const key in dirtyFields) { for (const key in dirtyFields) {
if (!Object.hasOwn(dirtyFields, key)) continue; if (!Object.hasOwn(dirtyFields, key)) continue;
const typedKey = key as keyof T; const isDirty = dirtyFields[key];
const isDirty = dirtyFields[typedKey]; const value = values[key];
const value = values[typedKey];
if (isDirty === true) { if (isDirty === true) {
result[typedKey] = value; // 🔹 Campo "leaf": se ha tocado → copiar valor
continue; result[key] = value;
} } else if (typeof isDirty === "object" && isDirty !== null) {
// 🔹 Campo anidado: recursión
if (typeof isDirty !== "object" || isDirty === null) { const nested = pickFormDirtyValues(value, isDirty);
continue;
}
const shouldReplaceTopLevelArray =
replaceTopLevelArrayKeys.includes(typedKey) &&
Array.isArray(value) &&
formHasAnyDirty(isDirty);
if (shouldReplaceTopLevelArray) {
result[typedKey] = value;
continue;
}
const nested = pickFormDirtyValues(value, isDirty as Partial<Record<keyof typeof value, any>>);
if (Object.keys(nested).length > 0) { if (Object.keys(nested).length > 0) {
result[typedKey] = nested as T[keyof T]; result[key] = nested as any;
}
} }
} }
@ -63,7 +37,7 @@ export function formHasAnyDirty(dirtyFields: Partial<Record<string, any>> | bool
if (dirtyFields === false || dirtyFields == null) return false; if (dirtyFields === false || dirtyFields == null) return false;
if (typeof dirtyFields === "object") { if (typeof dirtyFields === "object") {
return Object.values(dirtyFields).some((value) => formHasAnyDirty(value)); return Object.values(dirtyFields).some((v) => formHasAnyDirty(v));
} }
return false; return false;

View File

@ -3,12 +3,13 @@ import type { ICatalogs } from "@erp/core/api";
import { import {
CreateProformaInputMapper, CreateProformaInputMapper,
type ICreateProformaInputMapper, type ICreateProformaInputMapper,
type IUpdateProformaInputMapper,
UpdateProformaInputMapper, UpdateProformaInputMapper,
} from "../mappers"; } from "../mappers";
export interface IProformaInputMappers { export interface IProformaInputMappers {
createInputMapper: ICreateProformaInputMapper; createInputMapper: ICreateProformaInputMapper;
updateInputMapper: UpdateProformaInputMapper; updateInputMapper: IUpdateProformaInputMapper;
} }
export const buildProformaInputMappers = (catalogs: ICatalogs): IProformaInputMappers => { export const buildProformaInputMappers = (catalogs: ICatalogs): IProformaInputMappers => {

View File

@ -1,7 +1,7 @@
import type { ITransactionManager } from "@erp/core/api"; import type { ITransactionManager } from "@erp/core/api";
import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, UpdateProformaInputMapper } from "../mappers"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
import type { import type {
IProformaCreator, IProformaCreator,
IProformaFinder, IProformaFinder,
@ -97,7 +97,7 @@ export function buildIssueProformaUseCase(deps: {
export function buildUpdateProformaUseCase(deps: { export function buildUpdateProformaUseCase(deps: {
updater: IProformaUpdater; updater: IProformaUpdater;
dtoMapper: UpdateProformaInputMapper; dtoMapper: IUpdateProformaInputMapper;
fullSnapshotBuilder: IProformaFullSnapshotBuilder; fullSnapshotBuilder: IProformaFullSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {

View File

@ -1,5 +1,3 @@
import { NumberHelper } from "@erp/core";
import { DiscountPercentage } from "@erp/core/api";
import { import {
CurrencyCode, CurrencyCode,
DomainError, DomainError,
@ -15,14 +13,7 @@ import {
import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import type { UpdateProformaByIdRequestDTO } from "../../../../common/dto"; import type { UpdateProformaByIdRequestDTO } from "../../../../common/dto";
import { import { InvoiceSerie, type ProformaPatchProps } from "../../../domain";
InvoiceSerie,
ItemAmount,
ItemDescription,
ItemQuantity,
type ProformaItemPatchProps,
type ProformaPatchProps,
} from "../../../domain";
/** /**
* UpdateProformaPropsMapper * UpdateProformaPropsMapper
@ -46,10 +37,7 @@ export interface IUpdateProformaInputMapper {
} }
export class UpdateProformaInputMapper implements IUpdateProformaInputMapper { export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
public map( public map(dto: UpdateProformaByIdRequestDTO, params: { companyId: UniqueID }) {
dto: UpdateProformaByIdRequestDTO,
params: { companyId: UniqueID }
): Result<ProformaPatchProps> {
try { try {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
const props: ProformaPatchProps = {}; const props: ProformaPatchProps = {};
@ -140,149 +128,15 @@ export class UpdateProformaInputMapper implements IUpdateProformaInputMapper {
); );
}); });
if (dto.items) { if (errors.length > 0) {
const itemsProps = this.mapItemsProps(dto, { errors }); return Result.fail(
props.items = itemsProps; new ValidationErrorCollection("Proforma invoice props mapping failed (update)", errors)
);
} }
this.throwIfValidationErrors(errors);
return Result.ok(props); return Result.ok(props);
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(new DomainError("Proforma proforma props mapping failed", { cause: err })); return Result.fail(new DomainError("Proforma invoice props mapping failed", { cause: err }));
} }
} }
private throwIfValidationErrors(errors: ValidationErrorDetail[]): void {
if (errors.length > 0) {
throw new ValidationErrorCollection("Customer proforma props mapping failed", errors);
}
}
private mapItemsProps(
dto: UpdateProformaByIdRequestDTO,
params: {
errors: ValidationErrorDetail[];
}
): ProformaItemPatchProps[] {
const itemsProps: ProformaItemPatchProps[] = [];
dto.items?.forEach((item, index) => {
const description = extractOrPushError(
maybeFromNullableResult(item.description, (v) => ItemDescription.create(v)),
`items[${index}].description`,
params.errors
);
const quantity = extractOrPushError(
maybeFromNullableResult(item.quantity, (v) =>
ItemQuantity.create({ value: NumberHelper.toSafeNumber(v) })
),
`items[${index}].quantity`,
params.errors
);
const unitAmount = extractOrPushError(
maybeFromNullableResult(item.unit_amount, (v) =>
ItemAmount.create({ value: NumberHelper.toSafeNumber(v) })
),
`items[${index}].unit_amount`,
params.errors
);
const discountPercentage = extractOrPushError(
maybeFromNullableResult(item.item_discount_percentage, (v) =>
DiscountPercentage.create({ value: NumberHelper.toSafeNumber(v.value) })
),
`items[${index}].discount_percentage`,
params.errors
);
/*const taxes = this.mapTaxesProps(item.taxes, {
itemIndex: index,
errors: params.errors,
});*/
this.throwIfValidationErrors(params.errors);
itemsProps.push({
description: description!,
quantity: quantity!,
unitAmount: unitAmount!,
itemDiscountPercentage: discountPercentage!,
//taxes,
});
});
return itemsProps;
}
/* Devuelve las propiedades de los impustos de una línea de detalle */
/*private mapTaxesProps(
taxesDTO: Pick<ProformaItemRequestDTO, "taxes">["taxes"],
params: { itemIndex: number; errors: ValidationErrorDetail[] }
): ProformaItemTaxesProps {
const { itemIndex, errors } = params;
const taxesProps: ProformaItemTaxesProps = {
iva: Maybe.none(),
retention: Maybe.none(),
rec: Maybe.none(),
};
// Normaliza: "" -> []
const taxStrCodes = taxesDTO
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
taxStrCodes.forEach((strCode, taxIndex) => {
const taxResult = Tax.createFromCode(strCode, this.taxCatalog);
if (!taxResult.isSuccess) {
errors.push({
path: `items[${itemIndex}].taxes[${taxIndex}]`,
message: taxResult.error.message,
});
return;
}
const tax = taxResult.data;
if (tax.isVATLike()) {
if (taxesProps.iva.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group VAT are not allowed",
});
}
taxesProps.iva = Maybe.some(tax);
}
if (tax.isRetention()) {
if (taxesProps.retention.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group retention are not allowed",
});
}
taxesProps.retention = Maybe.some(tax);
}
if (tax.isRec()) {
if (taxesProps.rec.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group rec are not allowed",
});
}
taxesProps.rec = Maybe.some(tax);
}
});
this.throwIfValidationErrors(errors);
return taxesProps;
}*/
} }

View File

@ -1,7 +1,7 @@
import type { UniqueID } from "@repo/rdx-ddd"; import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Proforma, type ProformaCreateProps } from "../../../domain"; import { type IProformaCreateProps, Proforma } from "../../../domain";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { IProformaNumberGenerator } from "./proforma-number-generator.interface"; import type { IProformaNumberGenerator } from "./proforma-number-generator.interface";
@ -9,7 +9,7 @@ import type { IProformaNumberGenerator } from "./proforma-number-generator.inter
export interface IProformaCreatorParams { export interface IProformaCreatorParams {
companyId: UniqueID; companyId: UniqueID;
id: UniqueID; id: UniqueID;
props: Omit<ProformaCreateProps, "invoiceNumber">; props: Omit<IProformaCreateProps, "invoiceNumber">;
transaction: unknown; transaction: unknown;
} }

View File

@ -8,7 +8,7 @@ export interface IProformaUpdater {
update(params: { update(params: {
companyId: UniqueID; companyId: UniqueID;
id: UniqueID; id: UniqueID;
patchProps: ProformaPatchProps; props: ProformaPatchProps;
transaction: unknown; transaction: unknown;
}): Promise<Result<Proforma, Error>>; }): Promise<Result<Proforma, Error>>;
} }
@ -27,12 +27,12 @@ export class ProformaUpdater implements IProformaUpdater {
async update(params: { async update(params: {
companyId: UniqueID; companyId: UniqueID;
id: UniqueID; id: UniqueID;
patchProps: ProformaPatchProps; props: ProformaPatchProps;
transaction: unknown; transaction: unknown;
}): Promise<Result<Proforma, Error>> { }): Promise<Result<Proforma, Error>> {
const { companyId, id, patchProps, transaction } = params; const { companyId, id, props, transaction } = params;
console.log("patchProps => ", patchProps); console.log("props => ", props);
// Recuperar agregado existente // Recuperar agregado existente
const existingResult = await this.repository.getByIdInCompany(companyId, id, transaction); const existingResult = await this.repository.getByIdInCompany(companyId, id, transaction);
@ -44,12 +44,14 @@ export class ProformaUpdater implements IProformaUpdater {
const proforma = existingResult.data; const proforma = existingResult.data;
// Aplicar cambios en el agregado // Aplicar cambios en el agregado
const updateResult = proforma.update(patchProps); const updateResult = proforma.update(props);
if (updateResult.isFailure) { if (updateResult.isFailure) {
return Result.fail(updateResult.error); return Result.fail(updateResult.error);
} }
console.log(proforma.operationDate);
// Persistir cambios // Persistir cambios
const saveResult = await this.repository.update(proforma, transaction); const saveResult = await this.repository.update(proforma, transaction);

View File

@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
import type { UpdateProformaByIdRequestDTO } from "../../../../common"; import type { UpdateProformaByIdRequestDTO } from "../../../../common";
import type { ProformaPatchProps } from "../../../domain"; import type { ProformaPatchProps } from "../../../domain";
import type { UpdateProformaInputMapper } from "../mappers"; import type { IUpdateProformaInputMapper } from "../mappers";
import type { IProformaUpdater } from "../services"; import type { IProformaUpdater } from "../services";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
@ -15,14 +15,14 @@ type UpdateProformaUseCaseInput = {
}; };
type UpdateProformaUseCaseDeps = { type UpdateProformaUseCaseDeps = {
dtoMapper: UpdateProformaInputMapper; dtoMapper: IUpdateProformaInputMapper;
updater: IProformaUpdater; updater: IProformaUpdater;
fullSnapshotBuilder: IProformaFullSnapshotBuilder; fullSnapshotBuilder: IProformaFullSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}; };
export class UpdateProformaUseCase { export class UpdateProformaUseCase {
private readonly dtoMapper: UpdateProformaInputMapper; private readonly dtoMapper: IUpdateProformaInputMapper;
private readonly updater: IProformaUpdater; private readonly updater: IProformaUpdater;
private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder; private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder;
private readonly transactionManager: ITransactionManager; private readonly transactionManager: ITransactionManager;
@ -60,7 +60,7 @@ export class UpdateProformaUseCase {
const updateResult = await this.updater.update({ const updateResult = await this.updater.update({
companyId, companyId,
id: proformaId, id: proformaId,
patchProps, props: patchProps,
transaction, transaction,
}); });

View File

@ -24,7 +24,6 @@ import {
type IProformaItemCreateProps, type IProformaItemCreateProps,
type IProformaItems, type IProformaItems,
ProformaItem, ProformaItem,
type ProformaItemPatchProps,
ProformaItems, ProformaItems,
} from "../entities"; } from "../entities";
import { ProformaItemMismatch } from "../errors"; import { ProformaItemMismatch } from "../errors";
@ -57,10 +56,6 @@ export interface IProformaCreateProps {
globalDiscountPercentage: DiscountPercentage; globalDiscountPercentage: DiscountPercentage;
} }
export type ProformaPatchProps = Partial<Omit<IProformaCreateProps, "companyId" | "items">> & {
items?: ProformaItemPatchProps[];
};
export interface IProformaTotals { export interface IProformaTotals {
subtotalAmount: InvoiceAmount; subtotalAmount: InvoiceAmount;
@ -105,6 +100,10 @@ export interface IProforma {
totals(): IProformaTotals; totals(): IProformaTotals;
} }
export type ProformaPatchProps = Partial<Omit<IProformaCreateProps, "companyId" | "items">> & {
//items?: ProformaItems;
};
export type InternalProformaProps = Omit<IProformaCreateProps, "items">; export type InternalProformaProps = Omit<IProformaCreateProps, "items">;
export class Proforma extends AggregateRoot<InternalProformaProps> implements IProforma { export class Proforma extends AggregateRoot<InternalProformaProps> implements IProforma {
@ -157,47 +156,9 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
return new Proforma(props, items, id); return new Proforma(props, items, id);
} }
// Mutabilidad private initializeItems(itemsProps: IProformaItemCreateProps[]): Result<void, Error> {
public update(patchProps: ProformaPatchProps): Result<Proforma, Error> {
const { items, ...otherProps } = patchProps;
const candidateProps: InternalProformaProps = {
...this.props,
...otherProps,
};
// Validacciones
// Aplicar cambios
Object.assign(this.props, candidateProps);
// Reemplazo de items (si se proporciona)
if (items) {
this.initializeItems(items);
}
return Result.ok();
}
private initializeItems(
itemsProps: IProformaItemCreateProps[] | ProformaItemPatchProps[]
): Result<void, Error> {
this._items.reset();
for (const [index, itemProps] of itemsProps.entries()) { for (const [index, itemProps] of itemsProps.entries()) {
const { languageCode, currencyCode, globalDiscountPercentage, ...restProps } = { const itemResult = ProformaItem.create(itemProps);
languageCode: this.languageCode,
currencyCode: this.currencyCode,
globalDiscountPercentage: this.globalDiscountPercentage,
...itemProps,
};
const itemResult = ProformaItem.create({
...restProps,
languageCode,
currencyCode,
globalDiscountPercentage,
});
if (itemResult.isFailure) { if (itemResult.isFailure) {
return Result.fail(itemResult.error); return Result.fail(itemResult.error);
@ -285,6 +246,20 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
return this.paymentMethod.isSome(); return this.paymentMethod.isSome();
} }
// Mutabilidad
public update(patch: ProformaPatchProps): Result<Proforma, Error> {
const candidateProps: InternalProformaProps = {
...this.props,
...patch,
};
// Validacciones
Object.assign(this.props, candidateProps);
return Result.ok();
}
public issue(): Result<void, Error> { public issue(): Result<void, Error> {
if (!this.props.status.canTransitionTo("issued")) { if (!this.props.status.canTransitionTo("issued")) {
return Result.fail( return Result.fail(
@ -299,6 +274,7 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
this.props.status = InvoiceStatus.issued(); this.props.status = InvoiceStatus.issued();
return Result.ok(); return Result.ok();
} }
// Cálculos // Cálculos
/** /**
@ -350,6 +326,25 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
return Result.ok(); return Result.ok();
} }
/*public updateItem(itemId: UniqueID, props: IProformaItemProps): Result<void, Error> {
const item = this._items.find((i) => i.id.equals(itemId));
if (!item) {
return Result.fail(new Error("Item not found"));
}
return item.update(props);
}*/
/*public removeItem(itemId: UniqueID): Result<void, Error> {
const removed = this._items.removeWhere(i => i.id.equals(itemId));
if (!removed) {
return Result.fail(new Error("Item not found"));
}
return Result.ok();
}*/
// Helpers // Helpers
/** /**

View File

@ -42,11 +42,6 @@ export interface IProformaItemCreateProps {
currencyCode: CurrencyCode; // Para cálculos y formateos de moneda currencyCode: CurrencyCode; // Para cálculos y formateos de moneda
} }
export type ProformaItemPatchProps = Omit<
IProformaItemCreateProps,
"globalDiscountPercentage" | "languageCode" | "currencyCode"
>;
export interface IProformaItemTotals { export interface IProformaItemTotals {
subtotalAmount: ItemAmount; subtotalAmount: ItemAmount;

View File

@ -17,12 +17,12 @@ import {
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { import {
type IProformaCreateProps,
IssuedInvoiceItem, IssuedInvoiceItem,
ItemAmount, ItemAmount,
ItemDescription, ItemDescription,
ItemQuantity, ItemQuantity,
type Proforma, type Proforma,
type ProformaCreateProps,
} from "../../../../../../domain"; } from "../../../../../../domain";
export interface ICustomerInvoiceItemDomainMapper export interface ICustomerInvoiceItemDomainMapper
@ -62,7 +62,7 @@ export class CustomerInvoiceItemDomainMapper
const { errors, index, attributes } = params as { const { errors, index, attributes } = params as {
index: number; index: number;
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
attributes: Partial<ProformaCreateProps>; attributes: Partial<IProformaCreateProps>;
}; };
const itemId = extractOrPushError( const itemId = extractOrPushError(
@ -157,7 +157,7 @@ export class CustomerInvoiceItemDomainMapper
const { errors, index } = params as { const { errors, index } = params as {
index: number; index: number;
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
attributes: Partial<ProformaCreateProps>; attributes: Partial<IProformaCreateProps>;
}; };
// 1) Valores escalares (atributos generales) // 1) Valores escalares (atributos generales)

View File

@ -20,12 +20,12 @@ import { Maybe, Result, isNullishOrEmpty } from "@repo/rdx-utils";
import { import {
CustomerInvoiceItems, CustomerInvoiceItems,
type IProformaCreateProps,
InvoiceNumber, InvoiceNumber,
InvoicePaymentMethod, InvoicePaymentMethod,
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
Proforma, Proforma,
type ProformaCreateProps,
} from "../../../../../../domain"; } from "../../../../../../domain";
import type { import type {
CustomerInvoiceCreationAttributes, CustomerInvoiceCreationAttributes,
@ -249,7 +249,7 @@ export class CustomerInvoiceDomainMapper
items: itemsResults.data.getAll(), items: itemsResults.data.getAll(),
}); });
const invoiceProps: ProformaCreateProps = { const invoiceProps: IProformaCreateProps = {
companyId: attributes.companyId!, companyId: attributes.companyId!,
isProforma: attributes.isProforma, isProforma: attributes.isProforma,

View File

@ -16,9 +16,9 @@ import {
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { import {
type IProformaCreateProps,
InvoiceRecipient, InvoiceRecipient,
type Proforma, type Proforma,
type ProformaCreateProps,
} from "../../../../../../domain"; } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../sequelize"; import type { CustomerInvoiceModel } from "../../../../sequelize";
@ -34,7 +34,7 @@ export class InvoiceRecipientDomainMapper {
const { errors, attributes } = params as { const { errors, attributes } = params as {
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
attributes: Partial<ProformaCreateProps>; attributes: Partial<IProformaCreateProps>;
}; };
const { isProforma } = attributes; const { isProforma } = attributes;

View File

@ -12,8 +12,8 @@ import {
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { import {
type IProformaCreateProps,
type Proforma, type Proforma,
type ProformaCreateProps,
VerifactuRecord, VerifactuRecord,
VerifactuRecordEstado, VerifactuRecordEstado,
} from "../../../../../../domain"; } from "../../../../../../domain";
@ -43,7 +43,7 @@ export class CustomerInvoiceVerifactuDomainMapper
): Result<Maybe<VerifactuRecord>, Error> { ): Result<Maybe<VerifactuRecord>, Error> {
const { errors, attributes } = params as { const { errors, attributes } = params as {
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
attributes: Partial<ProformaCreateProps>; attributes: Partial<IProformaCreateProps>;
}; };
if (!source) { if (!source) {

View File

@ -17,6 +17,7 @@ import { Maybe, Result } from "@repo/rdx-utils";
import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../../common"; import type { CreateProformaItemRequestDTO, CreateProformaRequestDTO } from "../../../../../common";
import { import {
type IProformaCreateProps,
type IProformaItemCreateProps, type IProformaItemCreateProps,
InvoiceNumber, InvoiceNumber,
InvoicePaymentMethod, InvoicePaymentMethod,
@ -28,7 +29,6 @@ import {
ItemAmount, ItemAmount,
ItemDescription, ItemDescription,
ItemQuantity, ItemQuantity,
type ProformaCreateProps,
} from "../../../../domain"; } from "../../../../domain";
/** /**
@ -149,7 +149,7 @@ export class CreateProformaRequestMapper {
); );
} }
const proformaProps: Omit<ProformaCreateProps, "items"> & { items: IProformaItemCreateProps[] } = { const proformaProps: Omit<IProformaCreateProps, "items"> & { items: IProformaItemCreateProps[] } = {
companyId, companyId,
status: defaultStatus!, status: defaultStatus!,

View File

@ -16,12 +16,12 @@ import {
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { import {
type IProformaCreateProps,
type IProformaItemCreateProps, type IProformaItemCreateProps,
ItemAmount, ItemAmount,
ItemDescription, ItemDescription,
ItemQuantity, ItemQuantity,
type Proforma, type Proforma,
type ProformaCreateProps,
ProformaItem, ProformaItem,
ProformaItemTaxes, ProformaItemTaxes,
type ProformaItemTaxesProps, type ProformaItemTaxesProps,
@ -58,7 +58,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const { errors, index, parent } = params as { const { errors, index, parent } = params as {
index: number; index: number;
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
parent: Partial<ProformaCreateProps>; parent: Partial<IProformaCreateProps>;
}; };
const itemId = extractOrPushError( const itemId = extractOrPushError(
@ -139,7 +139,7 @@ export class SequelizeProformaItemDomainMapper extends SequelizeDomainMapper<
const { errors, index } = params as { const { errors, index } = params as {
index: number; index: number;
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
parent: Partial<ProformaCreateProps>; parent: Partial<IProformaCreateProps>;
}; };
// 1) Valores escalares (atributos generales) // 1) Valores escalares (atributos generales)

View File

@ -14,7 +14,7 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import { InvoiceRecipient, type ProformaCreateProps } from "../../../../../../domain"; import { type IProformaCreateProps, InvoiceRecipient } from "../../../../../../domain";
import type { CustomerInvoiceModel } from "../../../../../common"; import type { CustomerInvoiceModel } from "../../../../../common";
export class SequelizeProformaRecipientDomainMapper { export class SequelizeProformaRecipientDomainMapper {
@ -28,7 +28,7 @@ export class SequelizeProformaRecipientDomainMapper {
const { errors, parent } = params as { const { errors, parent } = params as {
errors: ValidationErrorDetail[]; errors: ValidationErrorDetail[];
parent: Partial<ProformaCreateProps>; parent: Partial<IProformaCreateProps>;
}; };
const _name = source.current_customer.name; const _name = source.current_customer.name;

View File

@ -4,14 +4,11 @@ import { z } from "zod/v4";
export const UpdateProformaItemRequestSchema = z.object({ export const UpdateProformaItemRequestSchema = z.object({
id: z.uuid(), id: z.uuid(),
position: z.string(), position: z.string(),
description: z.string().default(""), description: z.string().optional(),
quantity: NumericStringSchema.default(""), quantity: NumericStringSchema.optional(),
unit_amount: NumericStringSchema.default(""), unit_amount: NumericStringSchema.optional(),
item_discount_percentage: PercentageSchema.default({ item_discount_percentage: PercentageSchema.optional(),
value: "0", taxes: z.string().optional(),
scale: "2",
}),
taxes: z.string().default(""),
}); });
export const UpdateProformaByIdParamsRequestSchema = z.object({ export const UpdateProformaByIdParamsRequestSchema = z.object({

View File

@ -0,0 +1,142 @@
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { useEffect, useId, useMemo } from "react";
import { type FieldErrors, FormProvider } from "react-hook-form";
import { useTranslation } from "../../../i18n";
import type { Proforma } from "../../api";
import { type ProformaFormData, ProformaFormSchema, defaultProformaFormData } from "../types";
export interface UseProformaUpdateControllerOptions {
onUpdated?(updated: Proforma): void;
successToasts?: boolean; // mostrar o no toast automáticcamente
onError?(error: Error, patchData: ReturnType<typeof pickFormDirtyValues>): void;
errorToasts?: boolean; // mostrar o no toast automáticcamente
}
export const useProformaUpdateController = (
proformaId?: string,
options?: UseProformaUpdateControllerOptions
) => {
const { t } = useTranslation();
const formId = useId(); // id único por instancia
// 1) Estado de carga del cliente (query)
const {
data: proformaData,
isLoading,
isError: isLoadError,
error: loadError,
} = useProformaGetQuery(proformaId, { enabled: Boolean(proformaId) });
// 2) Estado de creación (mutación)
const {
mutateAsync,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useProformaUpdateMutation();
const initialValues = useMemo(() => proformaData ?? defaultProformaFormData, [proformaData]);
// 3) Form hook
const form = useHookForm<ProformaFormData>({
resolverSchema: ProformaFormSchema,
initialValues,
disabled: isLoading || isUpdating,
});
/** Reiniciar el form al recibir datos */
useEffect(() => {
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
if (proformaData) form.reset(proformaData, { keepDirty: false });
}, [proformaData, form]);
/** Handlers */
const resetForm = () => form.reset(proformaData ?? defaultProformaFormData);
// Versión sincronizada
const submitHandler = form.handleSubmit(
async (formData) => {
if (!proformaId) {
showErrorToast(t("pages.update.error.title"), "Falta el ID de la proforma");
return;
}
const { dirtyFields } = form.formState;
if (!formHasAnyDirty(dirtyFields)) {
showWarningToast(t("pages.update.error.no_changes"), "No hay cambios para guardar");
return;
}
const patchData = pickFormDirtyValues(formData, dirtyFields);
const previousData = proformaData;
try {
// Enviamos cambios al servidor
const updated = await mutateAsync({ id: proformaId, data: patchData });
// Ha ido bien -> actualizamos form con datos reales
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
form.reset(updated, { keepDirty: false });
if (options?.successToasts !== false) {
showSuccessToast(
t("pages.update.success.title", "Proforma modificada"),
t("pages.update.success.message", "Se ha modificado correctamente.")
);
}
options?.onUpdated?.(updated);
} catch (error: any) {
// Algo ha fallado -> revertimos cambios
form.reset(previousData ?? defaultProformaFormData);
if (options?.errorToasts !== false) {
showErrorToast(t("pages.update.error.title"), error.message);
}
options?.onError?.(error, patchData);
}
},
(errors: FieldErrors<ProformaFormData>) => {
const firstKey = Object.keys(errors)[0] as keyof ProformaFormData | undefined;
if (firstKey) document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
showWarningToast(
t("forms.validation.title", "Revisa los campos"),
t("forms.validation.message", "Hay errores de validación en el formulario.")
);
}
);
// Evento onSubmit ya preparado para el <form>
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
submitHandler(event);
};
return {
// form
form,
formId,
// handlers del form
onSubmit,
resetForm,
// carga de datos
proformaData,
isLoading,
isLoadError,
loadError,
// mutation
isUpdating,
isUpdateError,
updateError,
// Por comodidad
FormProvider,
};
};

View File

@ -1,4 +1,3 @@
import { formHasAnyDirty } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks"; import { useHookForm } from "@erp/core/hooks";
import type { CustomerSelectionOption } from "@erp/customers"; import type { CustomerSelectionOption } from "@erp/customers";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
@ -115,19 +114,12 @@ export const useUpdateProformaController = (
return; return;
} }
console.log(form.formState.dirtyFields);
if (!formHasAnyDirty(form.formState.dirtyFields)) {
showWarningToast(
t("proformas.update.no_changes.title"),
t("proformas.update.no_changes.message")
);
return;
}
const previousData = proformaData; const previousData = proformaData;
const patchData = buildProformaUpdatePatch(formData, form.formState.dirtyFields); const patchData = buildProformaUpdatePatch(formData, form.formState.dirtyFields);
console.log(patchData);
const params = buildUpdateProformaByIdParams(proformaId, patchData); const params = buildUpdateProformaByIdParams(proformaId, patchData);
try { try {
@ -140,7 +132,7 @@ export const useUpdateProformaController = (
keepDirty: false, keepDirty: false,
}); });
setSelectedCustomer(mapProformaToSelectedCustomer(updated)); setSelectedCustomer(mapProformaToSelectedCustomer(proformaData));
if (options?.successToasts !== false) { if (options?.successToasts !== false) {
showSuccessToast( showSuccessToast(
@ -161,8 +153,6 @@ export const useUpdateProformaController = (
{ keepDirty: false } { keepDirty: false }
); );
setSelectedCustomer(previousData ? mapProformaToSelectedCustomer(previousData) : null);
if (options?.errorToasts !== false) { if (options?.errorToasts !== false) {
showErrorToast(t("proformas.update.error.title"), normalizedError.message); showErrorToast(t("proformas.update.error.title"), normalizedError.message);
} }

View File

@ -0,0 +1,14 @@
import type { ProformaItemUpdateForm, ProformaItemUpdatePatch } from "../entities";
export const buildProformaItemsUpdatePatch = (
items: ProformaItemUpdateForm[]
): ProformaItemUpdatePatch[] => {
return items.map((item, index) => ({
id: item.id,
position: index,
description: item.description.trim(),
quantity: item.quantity,
unitAmount: item.unitAmount,
itemDiscountPercentage: item.itemDiscountPercentage,
}));
};

View File

@ -3,6 +3,8 @@ import type { FieldNamesMarkedBoolean } from "react-hook-form";
import type { ProformaUpdateForm, ProformaUpdatePatch } from "../entities"; import type { ProformaUpdateForm, ProformaUpdatePatch } from "../entities";
import { buildProformaItemsUpdatePatch } from "./build-proforma-items-update-patch";
export const buildProformaUpdatePatch = ( export const buildProformaUpdatePatch = (
formData: ProformaUpdateForm, formData: ProformaUpdateForm,
dirtyFields: FieldNamesMarkedBoolean<ProformaUpdateForm> dirtyFields: FieldNamesMarkedBoolean<ProformaUpdateForm>
@ -11,7 +13,10 @@ export const buildProformaUpdatePatch = (
return {}; return {};
} }
return pickFormDirtyValues(formData, dirtyFields, { const itemsPatch = buildProformaItemsUpdatePatch(formData.items);
replaceTopLevelArrayKeys: ["items"],
}) satisfies ProformaUpdatePatch; return {
...pickFormDirtyValues(formData, dirtyFields),
items: itemsPatch,
} as ProformaUpdatePatch;
}; };

View File

@ -23,24 +23,12 @@ export const buildUpdateProformaByIdParams = (
language_code: patch.languageCode, language_code: patch.languageCode,
currency_code: patch.currencyCode, currency_code: patch.currencyCode,
items: patch.items?.map((item) => ({
id: item.id,
position: String(item.position),
description: item.description,
quantity: item.quantity === null ? undefined : String(item.quantity),
unit_amount: item.unitAmount === null ? undefined : String(item.unitAmount),
item_discount_percentage:
item.itemDiscountPercentage === null
? undefined
: { value: String(item.itemDiscountPercentage), scale: "2" },
})),
}; };
return { return {
id, id,
data: Object.fromEntries( data: {
Object.entries(data).filter(([, value]) => value !== undefined) ...Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)),
) satisfies UpdateProformaByIdParams["data"], },
}; };
}; };