Importación desde FactuGES con validación

This commit is contained in:
David Arranz 2026-03-25 16:51:29 +01:00
parent 2ae118d1ff
commit 964565a6fe
6 changed files with 172 additions and 15 deletions

0
AGENTS.md Normal file
View File

View File

@ -60,7 +60,7 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
payment_method: payment, payment_method: payment,
subtotal_amount: allTotals.subtotalAmount.toObjectString(), subtotal_amount: allTotals.subtotalAmount.toObjectString(),
items_discount_amount: allTotals.itemDiscountAmount.toObjectString(), items_discount_amount: allTotals.itemsDiscountAmount.toObjectString(),
global_discount_percentage: proforma.globalDiscountPercentage.toObjectString(), global_discount_percentage: proforma.globalDiscountPercentage.toObjectString(),
global_discount_amount: allTotals.globalDiscountAmount.toObjectString(), global_discount_amount: allTotals.globalDiscountAmount.toObjectString(),

View File

@ -59,7 +59,7 @@ export interface IProformaCreateProps {
export interface IProformaTotals { export interface IProformaTotals {
subtotalAmount: InvoiceAmount; subtotalAmount: InvoiceAmount;
itemDiscountAmount: InvoiceAmount; itemsDiscountAmount: InvoiceAmount;
globalDiscountAmount: InvoiceAmount; globalDiscountAmount: InvoiceAmount;
totalDiscountAmount: InvoiceAmount; totalDiscountAmount: InvoiceAmount;
@ -276,7 +276,7 @@ export class Proforma extends AggregateRoot<InternalProformaProps> implements IP
return { return {
subtotalAmount: this.toInvoiceAmount(itemsTotals.subtotalAmount), subtotalAmount: this.toInvoiceAmount(itemsTotals.subtotalAmount),
itemDiscountAmount: this.toInvoiceAmount(itemsTotals.itemDiscountAmount), itemsDiscountAmount: this.toInvoiceAmount(itemsTotals.itemDiscountAmount),
globalDiscountAmount: this.toInvoiceAmount(itemsTotals.globalDiscountAmount), globalDiscountAmount: this.toInvoiceAmount(itemsTotals.globalDiscountAmount),
totalDiscountAmount: this.toInvoiceAmount(itemsTotals.totalDiscountAmount), totalDiscountAmount: this.toInvoiceAmount(itemsTotals.totalDiscountAmount),

View File

@ -339,8 +339,8 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
subtotal_amount_value: allAmounts.subtotalAmount.value, subtotal_amount_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale, subtotal_amount_scale: allAmounts.subtotalAmount.scale,
items_discount_amount_value: allAmounts.itemDiscountAmount.value, items_discount_amount_value: allAmounts.itemsDiscountAmount.value,
items_discount_amount_scale: allAmounts.itemDiscountAmount.scale, items_discount_amount_scale: allAmounts.itemsDiscountAmount.scale,
global_discount_percentage_value: source.globalDiscountPercentage.toPrimitive().value, global_discount_percentage_value: source.globalDiscountPercentage.toPrimitive().value,
global_discount_percentage_scale: source.globalDiscountPercentage.toPrimitive().scale, global_discount_percentage_scale: source.globalDiscountPercentage.toPrimitive().scale,

View File

@ -91,13 +91,13 @@ export type ProformaDraft = {
notes: Maybe<TextValue>; notes: Maybe<TextValue>;
languageCode: LanguageCode; languageCode: LanguageCode;
currencyCode: CurrencyCode; currencyCode: CurrencyCode;
subtotalAmount: Maybe<ItemAmount>; subtotalAmount: Maybe<InvoiceAmount>;
globalDiscountPercentage: DiscountPercentage; globalDiscountPercentage: DiscountPercentage;
itemsDiscountAmount: Maybe<ItemAmount>; itemsDiscountAmount: Maybe<InvoiceAmount>;
taxableAmount: Maybe<ItemAmount>; taxableAmount: Maybe<InvoiceAmount>;
taxes: ProformaItemTaxesProps; taxes: ProformaItemTaxesProps;
taxesAmount: Maybe<ItemAmount>; taxesAmount: Maybe<InvoiceAmount>;
totalAmount: Maybe<ItemAmount>; totalAmount: Maybe<InvoiceAmount>;
items: ProformaDraftItem[]; items: ProformaDraftItem[];
}; };

View File

@ -2,9 +2,12 @@ import type { JsonTaxCatalogProvider } from "@erp/core";
import { type ITransactionManager, Tax, isEntityNotFoundError } from "@erp/core/api"; import { type ITransactionManager, Tax, isEntityNotFoundError } from "@erp/core/api";
import type { ProformaPublicServices } from "@erp/customer-invoices/api"; import type { ProformaPublicServices } from "@erp/customer-invoices/api";
import { import {
type InvoiceAmount,
InvoicePaymentMethod, InvoicePaymentMethod,
type InvoiceRecipient, type InvoiceRecipient,
InvoiceStatus, InvoiceStatus,
type ItemAmount,
type Proforma,
} from "@erp/customer-invoices/api/domain"; } from "@erp/customer-invoices/api/domain";
import type { CustomerPublicServices } from "@erp/customers/api"; import type { CustomerPublicServices } from "@erp/customers/api";
import { import {
@ -13,7 +16,14 @@ import {
CustomerTaxes, CustomerTaxes,
type ICustomerCreateProps, type ICustomerCreateProps,
} from "@erp/customers/api/domain"; } from "@erp/customers/api/domain";
import { type Name, type PhoneNumber, type TextValue, UniqueID } from "@repo/rdx-ddd"; import {
type Name,
type PhoneNumber,
type TextValue,
UniqueID,
ValidationErrorCollection,
type ValidationErrorDetail,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import type { Transaction } from "sequelize"; import type { Transaction } from "sequelize";
@ -86,8 +96,8 @@ export class CreateProformaFromFactugesUseCase {
companyId, companyId,
transaction, transaction,
}); });
if (customerResult.isFailure) { if (paymentResult.isFailure) {
return Result.fail(customerResult.error); return Result.fail(paymentResult.error);
} }
const payment = paymentResult.data; const payment = paymentResult.data;
@ -122,6 +132,13 @@ export class CreateProformaFromFactugesUseCase {
return Result.fail(createResult.error); return Result.fail(createResult.error);
} }
// Valida que los datos de entrada coincidan con el snapshot
const proforma = createResult.data;
const validationResult = this.validateDraftAgainstProforma(proformaDraft, proforma);
if (validationResult.isFailure) {
return Result.fail(validationResult.error);
}
const readResult = await this.proformaServices.getProformaSnapshotById( const readResult = await this.proformaServices.getProformaSnapshotById(
createResult.data.id, createResult.data.id,
{ {
@ -136,8 +153,6 @@ export class CreateProformaFromFactugesUseCase {
const snapshot = readResult.data; const snapshot = readResult.data;
//const comparisonResults = this.compare()
const result = { const result = {
customer_id: customer.id.toString(), customer_id: customer.id.toString(),
proforma_id: snapshot.id.toString(), proforma_id: snapshot.id.toString(),
@ -150,6 +165,148 @@ export class CreateProformaFromFactugesUseCase {
}); });
} }
/**
* Valida que las magnitudes importadas del borrador coincidan con la proforma
* generada por el dominio.
*
* Motivo:
* - Detecta divergencias entre el payload legacy y los cálculos reales del dominio.
* - Actúa como validación de reconciliación, no como sustituto de las invariantes del agregado.
*/
private validateDraftAgainstProforma(
proformaDraft: FactugesProformaPayload["proformaDraft"],
proforma: Proforma
): Result<void, Error> {
const errors: ValidationErrorDetail[] = [];
const proformaTotals = proforma.totals();
console.log(proformaTotals);
if (proformaDraft.items.length !== proforma.items.size()) {
errors.push({
path: "items",
message: "La cantidad de ítems de la proforma no coincide con los datos de entrada.",
});
}
this.validateOptionalExpectedAmount({
expected: proformaDraft.subtotalAmount,
actual: proformaTotals.subtotalAmount,
path: "subtotalAmount",
message: "El subtotal de la proforma no coincide con los datos de entrada.",
errors,
});
this.validateOptionalExpectedAmount({
expected: proformaDraft.taxableAmount,
actual: proformaTotals.taxableAmount,
path: "taxableAmount",
message: "La base imponible de la proforma no coincide con los datos de entrada.",
errors,
});
this.validateOptionalExpectedAmount({
expected: proformaDraft.taxesAmount,
actual: proformaTotals.taxesAmount,
path: "taxesAmount",
message: "La suma de impuestos de la proforma no coincide con los datos de entrada.",
errors,
});
this.validateOptionalExpectedAmount({
expected: proformaDraft.totalAmount,
actual: proformaTotals.totalAmount,
path: "totalAmount",
message: "El total de la proforma no coincide con los datos de entrada.",
errors,
});
if (errors.length > 0) {
return Result.fail(
new ValidationErrorCollection(
"La proforma generada no coincide con las magnitudes validadas del borrador importado.",
errors
)
);
}
return Result.ok();
}
private validateOptionalExpectedAmount(params: {
expected: Maybe<InvoiceAmount | ItemAmount>;
actual: InvoiceAmount | ItemAmount;
path: string;
message: string;
errors: ValidationErrorDetail[];
}): void {
const { expected, actual, path, message, errors } = params;
if (expected.isNone()) {
return;
}
const expectedAmount = expected.unwrap();
if (!actual.equalsTo(expectedAmount)) {
errors.push({
path,
message: this.buildAmountMismatchMessage({
baseMessage: message,
expected: expectedAmount,
actual,
}),
});
}
}
private buildAmountMismatchMessage(params: {
baseMessage: string;
expected: InvoiceAmount | ItemAmount;
actual: InvoiceAmount | ItemAmount;
}): string {
const { baseMessage, expected, actual } = params;
return `${baseMessage} Esperado: ${expected.formattedValue}. Actual: ${actual.formattedValue}.`;
}
/**
* Valida un importe opcional esperado contra un importe real también opcional.
*
* Motivo:
* - Algunos campos pueden faltar tanto en el payload importado como en
* la proyección o snapshot generado.
* - Si el esperado existe pero el real no, se considera discrepancia.
*/
private validateOptionalMaybeAmount(params: {
expected: Maybe<InvoiceAmount | ItemAmount>;
actual: Maybe<InvoiceAmount | ItemAmount>;
path: string;
message: string;
errors: ValidationErrorDetail[];
}): void {
const { expected, actual, path, message, errors } = params;
if (expected.isNone()) {
return;
}
if (actual.isNone()) {
errors.push({
path,
message,
});
return;
}
if (!actual.unwrap().equals(expected.unwrap())) {
errors.push({
path,
message,
});
}
}
private buildProformaCreateProps(deps: { private buildProformaCreateProps(deps: {
proformaDraft: FactugesProformaPayload["proformaDraft"]; proformaDraft: FactugesProformaPayload["proformaDraft"];
customerId: UniqueID; customerId: UniqueID;