From 84bb21e5276401e9901dcedd6e92750732c49af8 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 3 Aug 2026 14:26:51 +0200 Subject: [PATCH] Refactor FactuGES Proforma Integration: Update Data Mapping and Add Documentation --- .../factuges/create-proforma-from-factuges.md | 37 +++++ .../validate-request.middleware.ts | 2 +- .../list/ui/pages/list-proformas-page.tsx | 25 ++-- ...ate-proforma-from-factuges-input.mapper.ts | 132 +++++++----------- .../create-proforma-from-factuges.use-case.ts | 46 +----- ...eate-proforma-from-factuges.request.dto.ts | 2 +- 6 files changed, 108 insertions(+), 136 deletions(-) create mode 100644 docs/factuges/create-proforma-from-factuges.md diff --git a/docs/factuges/create-proforma-from-factuges.md b/docs/factuges/create-proforma-from-factuges.md new file mode 100644 index 00000000..3e214a40 --- /dev/null +++ b/docs/factuges/create-proforma-from-factuges.md @@ -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. diff --git a/modules/core/src/api/infrastructure/express/middlewares/validate-request.middleware.ts b/modules/core/src/api/infrastructure/express/middlewares/validate-request.middleware.ts index 85ee00d0..e71ccbaf 100644 --- a/modules/core/src/api/infrastructure/express/middlewares/validate-request.middleware.ts +++ b/modules/core/src/api/infrastructure/express/middlewares/validate-request.middleware.ts @@ -42,7 +42,6 @@ export const validateRequest = ( ): RequestHandler => { return async (req, res, next) => { console.debug(`Validating request ${source} with schema.`); - console.debug(req[source]); if (!schema) { console.debug("ERROR: Undefined schema!!"); @@ -53,6 +52,7 @@ export const validateRequest = ( const result = schema.safeParse(req[source]); if (!result.success) { + console.debug(req[source]); console.debug("ERROR: Validation failed with errors!!"); // Construye errores detallados const validationErrors = result.error.issues.map((err) => ({ diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index 1acbdcf1..b5361478 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -8,6 +8,7 @@ import { CardContent, DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, @@ -191,14 +192,20 @@ const ProformasSelectionActions = ({ } /> - - Selección - - onDelete(selectedProformas)} variant="destructive"> - - + + Selección + + onDelete(selectedProformas)} variant="destructive"> + + + } + /> ) : null} @@ -333,7 +340,7 @@ const ScopedListProformasPage = ({ scope }: ScopedListProformasPageProps) => { onPageChange={listCtrl.setPageIndex} onPageSizeChange={listCtrl.setPageSize} onRowClick={ - scope !== "deleted" ? (proformaId) => handleOpenDetailClick(proformaId) : undefined + scope === "deleted" ? undefined : (proformaId) => handleOpenDetailClick(proformaId) } onSortChange={listCtrl.setSort} pageIndex={listCtrl.pageIndex} diff --git a/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts b/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts index 4d6d9067..023a4ce9 100644 --- a/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts +++ b/modules/factuges/src/api/application/mappers/create-proforma-from-factuges-input.mapper.ts @@ -7,7 +7,7 @@ import { } from "@erp/core/api"; import { InvoiceAmount, - InvoiceSerie, + type InvoiceSerie, ItemAmount, ItemDescription, ItemQuantity, @@ -89,7 +89,8 @@ export type ProformaDraftItem = { export type ProformaDraft = { factugesID: string; - series: Maybe; + proformaSeriesCode: Maybe; + targetInvoiceSeriesCode: Maybe; invoiceDate: UtcDate; operationDate: Maybe; reference: Maybe; @@ -101,6 +102,7 @@ export type ProformaDraft = { globalDiscountPercentage: DiscountPercentage; itemsDiscountAmount: Maybe; taxableAmount: Maybe; + taxConfig: FactugesDefaultProformaTaxConfig; taxes: ProformaItemTaxesProps; taxesAmount: Maybe; totalAmount: Maybe; @@ -120,6 +122,24 @@ export type FactugesProformaPayload = { proformaDraft: ProformaDraft; }; +type FactugesDefaultProformaTaxConfig = { + taxMode: "single"; + defaultIvaCode: Maybe; + usesEquivalenceSurcharge: false; + defaultRecCode: Maybe; + usesRetention: false; + defaultRetentionCode: Maybe; +}; + +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 { map( dto: CreateProformaFromFactugesRequestDTO, @@ -180,17 +200,17 @@ export class CreateProformaFromFactugesInputMapper } ): ProformaDraft { const errors: ValidationErrorDetail[] = []; - const { companyId } = params; //const defaultStatus = InvoiceStatus.fromApproved(); //const proformaId = extractOrPushError(UniqueID.create(dto.id), "id", errors); - const series = extractOrPushError( - maybeFromNullableResult(dto.series, (value) => InvoiceSerie.create(value)), - "series", - errors - ); + /** + * Complementa los datos legacy con los defaults actuales de proformas. + * + * La serie legacy `series` no se usa porque la numeracion documental + * se resuelve mediante `document-series`. + */ /*const proformaNumber = extractOrPushError( InvoiceNumber.create(dto.), @@ -207,7 +227,7 @@ export class CreateProformaFromFactugesInputMapper ); const invoiceDate = extractOrPushError( - UtcDate.createFromISO(dto.invoice_date), + UtcDate.createFromISO(dto.proforma_date), "invoice_date", errors ); @@ -304,7 +324,8 @@ export class CreateProformaFromFactugesInputMapper //invoiceNumber: proformaNumber!, factugesID: factugesID, - series: series!, + proformaSeriesCode: Maybe.none(), + targetInvoiceSeriesCode: Maybe.none(), invoiceDate: invoiceDate!, operationDate: operationDate!, @@ -324,6 +345,7 @@ export class CreateProformaFromFactugesInputMapper itemsDiscountAmount: itemsDiscountAmount!, taxableAmount: taxableAmount!, + taxConfig: FACTUGES_DEFAULT_PROFORMA_TAX_CONFIG, taxes: taxes, taxesAmount: taxesAmount!, totalAmount: totalAmount!, @@ -607,86 +629,28 @@ export class CreateProformaFromFactugesInputMapper /* Devuelve las propiedades de los impustos de una línea de detalle */ private mapItemTaxesProps( - itemDTO: CreateProformaItemFromFactugesRequestDTO, + _itemDTO: CreateProformaItemFromFactugesRequestDTO, params: { itemIndex: number; errors: ValidationErrorDetail[] } ): 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 = { - iva: Maybe.none(), + this.throwIfValidationErrors(params.errors); + + return { + iva: iva ?? Maybe.none(), retention: 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: { diff --git a/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts b/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts index a035c2e1..4330df3d 100644 --- a/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts +++ b/modules/factuges/src/api/application/use-cases/create-proforma-from-factuges.use-case.ts @@ -2,10 +2,10 @@ import { type ITransactionManager, isEntityNotFoundError } from "@erp/core/api"; import type { IProformaPublicServices } from "@erp/customer-invoices/api"; import { type InvoiceAmount, - type InvoiceRecipient, InvoiceStatus, type ItemAmount, type Proforma, + type ProformaRecipient, } from "@erp/customer-invoices/api/domain"; import type { ICustomerPublicServices } from "@erp/customers/api"; import { @@ -293,43 +293,6 @@ export class CreateProformaFromFactugesUseCase { 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; - actual: Maybe; - 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: { proformaDraft: FactugesProformaPayload["proformaDraft"]; customerId: UniqueID; @@ -343,10 +306,9 @@ export class CreateProformaFromFactugesUseCase { const { companyId } = context; const defaultStatus = InvoiceStatus.approved(); - const recipient = Maybe.none(); + const recipient = Maybe.none(); const linkedInvoiceId = Maybe.none(); const paymentMethodId = Maybe.some(payment.id); - const paymentTermId = Maybe.none(); const taxRegimeCode = Maybe.some("01"); const items: CreateProformaProps["items"] = proformaDraft.items.map((draftItem) => { @@ -382,8 +344,10 @@ export class CreateProformaFromFactugesUseCase { currencyCode: proformaDraft.currencyCode, notes: proformaDraft.notes, operationDate: proformaDraft.operationDate, - series: proformaDraft.series, + proformaSeriesCode: proformaDraft.proformaSeriesCode, + targetInvoiceSeriesCode: proformaDraft.targetInvoiceSeriesCode, reference: proformaDraft.reference, + taxConfig: proformaDraft.taxConfig, items, companyId, customerId, diff --git a/modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts b/modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts index 8351c388..c49e5779 100644 --- a/modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts +++ b/modules/factuges/src/common/dto/request/create-proforma-from-factuges.request.dto.ts @@ -49,7 +49,7 @@ export const CreateProformaFromFactugesRequestSchema = z.object({ reference: z.string().default(""), description: z.string().default(""), - invoice_date: z.string(), + proforma_date: z.string(), operation_date: z.string().default(""), notes: z.string().default(""),