Refactor FactuGES Proforma Integration: Update Data Mapping and Add Documentation

This commit is contained in:
David Arranz 2026-08-03 14:26:51 +02:00
parent 163f2b9fa4
commit 84bb21e527
6 changed files with 108 additions and 136 deletions

View File

@ -0,0 +1,37 @@
# FactuGES -> Proformas V2
## Resumen
FactuGES no envia `proformaSeriesCode`, `targetInvoiceSeriesCode` ni `taxConfig`.
El mapper de `factuges` complementa esos datos antes de delegar en el flujo actual de creacion de proformas V2 de `customer-invoices`.
## Reglas aplicadas
- `proformaSeriesCode = null`
- `targetInvoiceSeriesCode = null`
- `taxConfig.taxMode = single`
- `taxConfig.defaultIvaCode = iva_21`
- `taxConfig.usesEquivalenceSurcharge = false`
- `taxConfig.defaultRecCode = null`
- `taxConfig.usesRetention = false`
- `taxConfig.defaultRetentionCode = null`
## Semantica de series
- `proformaSeriesCode = null` permite que `document-series` asigne la serie por defecto de `proforma`
- `targetInvoiceSeriesCode = null` deja la futura factura emitida sin serie fijada y al emitir se usara la serie por defecto de `issued_invoice`
- el campo legacy `series` puede seguir llegando desde FactuGES, pero ya no se usa para crear la proforma V2
## Fiscalidad
- la cabecera de la proforma se crea con `tax_mode = single`
- las lineas se envian con `iva_code = iva_21`
- `rec_code` queda `null`
- `retention_code` queda `null`
## Estado inicial
El estado inicial se conserva segun el flujo actual de FactuGES.
Actualmente el caso de uso de FactuGES sigue creando la proforma con `status = approved`, por lo que esta adaptacion no fuerza `draft` ni introduce una regla nueva de estado.

View File

@ -42,7 +42,6 @@ export const validateRequest = <T extends "body" | "query" | "params">(
): RequestHandler => { ): RequestHandler => {
return async (req, res, next) => { return async (req, res, next) => {
console.debug(`Validating request ${source} with schema.`); console.debug(`Validating request ${source} with schema.`);
console.debug(req[source]);
if (!schema) { if (!schema) {
console.debug("ERROR: Undefined schema!!"); console.debug("ERROR: Undefined schema!!");
@ -53,6 +52,7 @@ export const validateRequest = <T extends "body" | "query" | "params">(
const result = schema.safeParse(req[source]); const result = schema.safeParse(req[source]);
if (!result.success) { if (!result.success) {
console.debug(req[source]);
console.debug("ERROR: Validation failed with errors!!"); console.debug("ERROR: Validation failed with errors!!");
// Construye errores detallados // Construye errores detallados
const validationErrors = result.error.issues.map((err) => ({ const validationErrors = result.error.issues.map((err) => ({

View File

@ -8,6 +8,7 @@ import {
CardContent, CardContent,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
@ -191,14 +192,20 @@ const ProformasSelectionActions = ({
</Button> </Button>
} }
/> />
<DropdownMenuContent align="start" className="w-52"> <DropdownMenuContent
<DropdownMenuLabel>Selección</DropdownMenuLabel> align="start"
<DropdownMenuSeparator /> className="w-52"
<DropdownMenuItem onClick={() => onDelete(selectedProformas)} variant="destructive"> render={
<Trash2Icon aria-hidden="true" className="mr-2 size-4" /> <DropdownMenuGroup>
<span>Eliminar borradores</span> <DropdownMenuLabel>Selección</DropdownMenuLabel>
</DropdownMenuItem> <DropdownMenuSeparator />
</DropdownMenuContent> <DropdownMenuItem onClick={() => onDelete(selectedProformas)} variant="destructive">
<Trash2Icon aria-hidden="true" className="mr-2 size-4" />
<span>Eliminar borradores</span>
</DropdownMenuItem>
</DropdownMenuGroup>
}
/>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
</> </>
@ -333,7 +340,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => {
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
onRowClick={ onRowClick={
scope !== "deleted" ? (proformaId) => handleOpenDetailClick(proformaId) : undefined scope === "deleted" ? undefined : (proformaId) => handleOpenDetailClick(proformaId)
} }
onSortChange={listCtrl.setSort} onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex} pageIndex={listCtrl.pageIndex}

View File

@ -7,7 +7,7 @@ import {
} from "@erp/core/api"; } from "@erp/core/api";
import { import {
InvoiceAmount, InvoiceAmount,
InvoiceSerie, type InvoiceSerie,
ItemAmount, ItemAmount,
ItemDescription, ItemDescription,
ItemQuantity, ItemQuantity,
@ -89,7 +89,8 @@ export type ProformaDraftItem = {
export type ProformaDraft = { export type ProformaDraft = {
factugesID: string; factugesID: string;
series: Maybe<InvoiceSerie>; proformaSeriesCode: Maybe<InvoiceSerie>;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
invoiceDate: UtcDate; invoiceDate: UtcDate;
operationDate: Maybe<UtcDate>; operationDate: Maybe<UtcDate>;
reference: Maybe<string>; reference: Maybe<string>;
@ -101,6 +102,7 @@ export type ProformaDraft = {
globalDiscountPercentage: DiscountPercentage; globalDiscountPercentage: DiscountPercentage;
itemsDiscountAmount: Maybe<InvoiceAmount>; itemsDiscountAmount: Maybe<InvoiceAmount>;
taxableAmount: Maybe<InvoiceAmount>; taxableAmount: Maybe<InvoiceAmount>;
taxConfig: FactugesDefaultProformaTaxConfig;
taxes: ProformaItemTaxesProps; taxes: ProformaItemTaxesProps;
taxesAmount: Maybe<InvoiceAmount>; taxesAmount: Maybe<InvoiceAmount>;
totalAmount: Maybe<InvoiceAmount>; totalAmount: Maybe<InvoiceAmount>;
@ -120,6 +122,24 @@ export type FactugesProformaPayload = {
proformaDraft: ProformaDraft; proformaDraft: ProformaDraft;
}; };
type FactugesDefaultProformaTaxConfig = {
taxMode: "single";
defaultIvaCode: Maybe<string>;
usesEquivalenceSurcharge: false;
defaultRecCode: Maybe<string>;
usesRetention: false;
defaultRetentionCode: Maybe<string>;
};
const FACTUGES_DEFAULT_PROFORMA_TAX_CONFIG: FactugesDefaultProformaTaxConfig = {
taxMode: "single",
defaultIvaCode: Maybe.some("iva_21"),
usesEquivalenceSurcharge: false,
defaultRecCode: Maybe.none(),
usesRetention: false,
defaultRetentionCode: Maybe.none(),
};
export interface ICreateProformaFromFactugesInputMapper { export interface ICreateProformaFromFactugesInputMapper {
map( map(
dto: CreateProformaFromFactugesRequestDTO, dto: CreateProformaFromFactugesRequestDTO,
@ -180,17 +200,17 @@ export class CreateProformaFromFactugesInputMapper
} }
): ProformaDraft { ): ProformaDraft {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
const { companyId } = params;
//const defaultStatus = InvoiceStatus.fromApproved(); //const defaultStatus = InvoiceStatus.fromApproved();
//const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors); //const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors);
const series = extractOrPushError( /**
maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)), * Complementa los datos legacy con los defaults actuales de proformas.
"series", *
errors * La serie legacy `series` no se usa porque la numeracion documental
); * se resuelve mediante `document-series`.
*/
/*const proformaNumber = extractOrPushError( /*const proformaNumber = extractOrPushError(
InvoiceNumber.create(dto.), InvoiceNumber.create(dto.),
@ -207,7 +227,7 @@ export class CreateProformaFromFactugesInputMapper
); );
const invoiceDate = extractOrPushError( const invoiceDate = extractOrPushError(
UtcDate.createFromISO(dto.invoice_date), UtcDate.createFromISO(dto.proforma_date),
"invoice_date", "invoice_date",
errors errors
); );
@ -304,7 +324,8 @@ export class CreateProformaFromFactugesInputMapper
//invoiceNumber: proformaNumber!, //invoiceNumber: proformaNumber!,
factugesID: factugesID, factugesID: factugesID,
series: series!, proformaSeriesCode: Maybe.none(),
targetInvoiceSeriesCode: Maybe.none(),
invoiceDate: invoiceDate!, invoiceDate: invoiceDate!,
operationDate: operationDate!, operationDate: operationDate!,
@ -324,6 +345,7 @@ export class CreateProformaFromFactugesInputMapper
itemsDiscountAmount: itemsDiscountAmount!, itemsDiscountAmount: itemsDiscountAmount!,
taxableAmount: taxableAmount!, taxableAmount: taxableAmount!,
taxConfig: FACTUGES_DEFAULT_PROFORMA_TAX_CONFIG,
taxes: taxes, taxes: taxes,
taxesAmount: taxesAmount!, taxesAmount: taxesAmount!,
totalAmount: totalAmount!, totalAmount: totalAmount!,
@ -607,86 +629,28 @@ export class CreateProformaFromFactugesInputMapper
/* Devuelve las propiedades de los impustos de una línea de detalle */ /* Devuelve las propiedades de los impustos de una línea de detalle */
private mapItemTaxesProps( private mapItemTaxesProps(
itemDTO: CreateProformaItemFromFactugesRequestDTO, _itemDTO: CreateProformaItemFromFactugesRequestDTO,
params: { itemIndex: number; errors: ValidationErrorDetail[] } params: { itemIndex: number; errors: ValidationErrorDetail[] }
): ProformaItemTaxesProps { ): ProformaItemTaxesProps {
const { itemIndex, errors } = params; const iva = extractOrPushError(
this.mapTaxToDomain({
code: "iva_21",
percentageValue: 21,
group: "iva",
calculationBehavior: "additive",
fieldPath: `items[${params.itemIndex}].iva`,
}),
`items[${params.itemIndex}].iva`,
params.errors
);
const taxesProps: ProformaItemTaxesProps = { this.throwIfValidationErrors(params.errors);
iva: Maybe.none(),
return {
iva: iva ?? Maybe.none(),
retention: Maybe.none(), retention: Maybe.none(),
rec: Maybe.none(), rec: Maybe.none(),
}; };
const iva = extractOrPushError(
this.mapTaxToDomain({
code: itemDTO.iva_code,
percentageValue: Number(itemDTO.iva_percentage_value),
group: "iva",
calculationBehavior: "additive",
fieldPath: `items[${itemIndex}].iva`,
}),
`items[${itemIndex}].iva`,
errors
);
const rec = extractOrPushError(
this.mapTaxToDomain({
code: itemDTO.rec_code,
percentageValue: Number(itemDTO.rec_percentage_value),
group: "surcharge",
calculationBehavior: "additive",
fieldPath: `items[${itemIndex}].rec`,
}),
`items[${itemIndex}].rec`,
errors
);
const retention = extractOrPushError(
this.mapTaxToDomain({
code: itemDTO.retention_code,
percentageValue: Number(itemDTO.retention_percentage_value),
group: "retention",
calculationBehavior: "subtractive",
fieldPath: `items[${itemIndex}].retention`,
}),
`items[${itemIndex}].retention`,
errors
);
if (iva) {
if (taxesProps.iva.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group VAT are not allowed",
});
}
taxesProps.iva = iva!;
}
if (rec) {
if (taxesProps.rec.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group rec are not allowed",
});
}
taxesProps.rec!;
}
if (retention) {
if (taxesProps.retention.isSome()) {
errors.push({
path: `items[${itemIndex}].taxes`,
message: "Multiple taxes for group retention are not allowed",
});
}
taxesProps.retention!;
}
this.throwIfValidationErrors(errors);
return taxesProps;
} }
private mapTaxToDomain(params: { private mapTaxToDomain(params: {

View File

@ -2,10 +2,10 @@ import { type ITransactionManager, isEntityNotFoundError } from "@erp/core/api";
import type { IProformaPublicServices } from "@erp/customer-invoices/api"; import type { IProformaPublicServices } from "@erp/customer-invoices/api";
import { import {
type InvoiceAmount, type InvoiceAmount,
type InvoiceRecipient,
InvoiceStatus, InvoiceStatus,
type ItemAmount, type ItemAmount,
type Proforma, type Proforma,
type ProformaRecipient,
} from "@erp/customer-invoices/api/domain"; } from "@erp/customer-invoices/api/domain";
import type { ICustomerPublicServices } from "@erp/customers/api"; import type { ICustomerPublicServices } from "@erp/customers/api";
import { import {
@ -293,43 +293,6 @@ export class CreateProformaFromFactugesUseCase {
return `${baseMessage} FactuGES: ${expected.formattedValue}. Calculado: ${actual.formattedValue}.`; return `${baseMessage} FactuGES: ${expected.formattedValue}. Calculado: ${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;
@ -343,10 +306,9 @@ export class CreateProformaFromFactugesUseCase {
const { companyId } = context; const { companyId } = context;
const defaultStatus = InvoiceStatus.approved(); const defaultStatus = InvoiceStatus.approved();
const recipient = Maybe.none<InvoiceRecipient>(); const recipient = Maybe.none<ProformaRecipient>();
const linkedInvoiceId = Maybe.none<UniqueID>(); const linkedInvoiceId = Maybe.none<UniqueID>();
const paymentMethodId = Maybe.some(payment.id); const paymentMethodId = Maybe.some(payment.id);
const paymentTermId = Maybe.none();
const taxRegimeCode = Maybe.some("01"); const taxRegimeCode = Maybe.some("01");
const items: CreateProformaProps["items"] = proformaDraft.items.map((draftItem) => { const items: CreateProformaProps["items"] = proformaDraft.items.map((draftItem) => {
@ -382,8 +344,10 @@ export class CreateProformaFromFactugesUseCase {
currencyCode: proformaDraft.currencyCode, currencyCode: proformaDraft.currencyCode,
notes: proformaDraft.notes, notes: proformaDraft.notes,
operationDate: proformaDraft.operationDate, operationDate: proformaDraft.operationDate,
series: proformaDraft.series, proformaSeriesCode: proformaDraft.proformaSeriesCode,
targetInvoiceSeriesCode: proformaDraft.targetInvoiceSeriesCode,
reference: proformaDraft.reference, reference: proformaDraft.reference,
taxConfig: proformaDraft.taxConfig,
items, items,
companyId, companyId,
customerId, customerId,

View File

@ -49,7 +49,7 @@ export const CreateProformaFromFactugesRequestSchema = z.object({
reference: z.string().default(""), reference: z.string().default(""),
description: z.string().default(""), description: z.string().default(""),
invoice_date: z.string(), proforma_date: z.string(),
operation_date: z.string().default(""), operation_date: z.string().default(""),
notes: z.string().default(""), notes: z.string().default(""),