feat(proformas): agregar contexto de generación de documentos y actualizar fábricas y servicios relacionados

This commit is contained in:
David Arranz 2026-09-21 16:58:06 +02:00
parent b9143d8878
commit fc8c7fea99
20 changed files with 357 additions and 71 deletions

View File

@ -21,26 +21,35 @@ import type { IDocumentSideEffect } from "./document-side-effect.interface";
* 5. Side-effects (persistencia, métricas) [best-effort]
*/
export type DocumentGenerationServiceDeps<TSnapshot> = {
renderer: IDocumentRenderer<TSnapshot>;
metadataFactory: IDocumentMetadataFactory<TSnapshot>;
propertiesFactory: IDocumentPropertiesFactory<TSnapshot>;
export type DocumentGenerationServiceDeps<TSnapshot, TContext = Record<string, unknown>> = {
renderer: IDocumentRenderer<TSnapshot, TContext & DocumentGenerationServiceRendererParams>;
metadataFactory: IDocumentMetadataFactory<TSnapshot, TContext>;
propertiesFactory: IDocumentPropertiesFactory<TSnapshot, TContext>;
preProcessors: readonly IDocumentPreProcessor[];
postProcessor: IDocumentPostProcessor;
sideEffects: readonly IDocumentSideEffect[];
};
export type DocumentGenerationServiceRenderParams = Record<string, unknown>;
export type DocumentGenerationServiceRendererParams = {
format: "PDF" | "HTML";
languageCode: string;
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class DocumentGenerationService<TSnapshot> {
private readonly renderer: IDocumentRenderer<TSnapshot>;
private readonly metadataFactory: IDocumentMetadataFactory<TSnapshot>;
private readonly propertiesFactory: IDocumentPropertiesFactory<TSnapshot>;
export class DocumentGenerationService<TSnapshot, TContext = Record<string, unknown>> {
private readonly renderer: IDocumentRenderer<
TSnapshot,
TContext & DocumentGenerationServiceRendererParams
>;
private readonly metadataFactory: IDocumentMetadataFactory<TSnapshot, TContext>;
private readonly propertiesFactory: IDocumentPropertiesFactory<TSnapshot, TContext>;
private readonly preProcessors: readonly IDocumentPreProcessor[];
private readonly postProcessor: IDocumentPostProcessor;
private readonly sideEffects: readonly IDocumentSideEffect[];
constructor(deps: DocumentGenerationServiceDeps<TSnapshot>) {
constructor(deps: DocumentGenerationServiceDeps<TSnapshot, TContext>) {
this.renderer = deps.renderer;
this.metadataFactory = deps.metadataFactory;
this.propertiesFactory = deps.propertiesFactory;
@ -51,7 +60,7 @@ export class DocumentGenerationService<TSnapshot> {
async generate(
snapshot: TSnapshot,
params: DocumentGenerationServiceRenderParams
params: TContext
): Promise<Result<IDocument, DocumentGenerationError>> {
let metadata: IDocumentMetadata;
let properties: IDocumentProperties;
@ -64,7 +73,7 @@ export class DocumentGenerationService<TSnapshot> {
}
try {
properties = this.propertiesFactory.build(snapshot);
properties = this.propertiesFactory.build(snapshot, params);
} catch (error) {
return Result.fail(DocumentGenerationError.properties(error));
}

View File

@ -1,5 +1,5 @@
import type { IDocumentMetadata } from "../application-models";
export interface IDocumentMetadataFactory<TSnapshot, TSnapshotContext = Record<string, string>> {
export interface IDocumentMetadataFactory<TSnapshot, TSnapshotContext = Record<string, unknown>> {
build(snapshot: TSnapshot, context: TSnapshotContext): IDocumentMetadata;
}

View File

@ -1,5 +1,5 @@
import type { IDocumentProperties } from "../application-models";
export interface IDocumentPropertiesFactory<TSnapshot> {
build(snapshot: TSnapshot): IDocumentProperties;
export interface IDocumentPropertiesFactory<TSnapshot, TContext = Record<string, unknown>> {
build(snapshot: TSnapshot, context: TContext): IDocumentProperties;
}

View File

@ -1,5 +1,5 @@
import type { IDocument } from "../application-models";
export interface IDocumentRenderer<TSource> {
render(source: TSource, params?: Record<string, unknown>): Promise<IDocument>;
export interface IDocumentRenderer<TSource, TRenderParams = Record<string, unknown>> {
render(source: TSource, params: TRenderParams): Promise<IDocument>;
}

View File

@ -18,6 +18,7 @@ import type {
IProformaStatusChanger,
IProformaUpdater,
ProformaDocumentGeneratorService,
ProformaPdfDocumentGenerationContext,
} from "../services";
import type {
IIssueProformaResponseSnapshotBuilder,
@ -88,7 +89,7 @@ export function buildReportProformaPdfUseCase(deps: {
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
documentService: ProformaDocumentGeneratorService<ProformaPdfDocumentGenerationContext>;
transactionManager: ITransactionManager;
}) {
return new ReportProformaPdfUseCase({

View File

@ -4,6 +4,7 @@ export * from "./company-report-profile-finder";
export * from "./proforma-archiver";
export * from "./proforma-creator";
export * from "./proforma-deleter";
export * from "./proforma-document-generation-context.interface";
export * from "./proforma-document-generator.interface";
export * from "./proforma-document-properties-factory";
export * from "./proforma-duplicator";

View File

@ -0,0 +1,17 @@
/**
* Datos externos al snapshot necesarios para resolver y generar una proforma.
*
* Los identificadores funcionales permanecen en el snapshot para evitar
* duplicar fuentes de verdad entre el caso de uso y el pipeline.
*/
export interface ProformaDocumentGenerationContext {
readonly companySlug: string;
readonly languageCode: string;
readonly companyLegalName: string;
}
/** Datos adicionales requeridos exclusivamente por el pipeline PDF y su cache. */
export interface ProformaPdfDocumentGenerationContext extends ProformaDocumentGenerationContext {
readonly currencyCode: string;
readonly companyVersion?: string;
}

View File

@ -2,5 +2,8 @@ import type { DocumentGenerationService } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../snapshot-builders";
export interface ProformaDocumentGeneratorService
extends DocumentGenerationService<ProformaReportSnapshot> {}
import type { ProformaDocumentGenerationContext } from "./proforma-document-generation-context.interface";
export interface ProformaDocumentGeneratorService<
TContext extends ProformaDocumentGenerationContext = ProformaDocumentGenerationContext,
> extends DocumentGenerationService<ProformaReportSnapshot, TContext> {}

View File

@ -2,6 +2,8 @@ import type { IDocumentProperties, IDocumentPropertiesFactory } from "@erp/core/
import type { ProformaReportSnapshot } from "../snapshot-builders";
import type { ProformaDocumentGenerationContext } from "./proforma-document-generation-context.interface";
/**
* Construye los metadatos del documento PDF de una factura emitida.
*
@ -10,13 +12,16 @@ import type { ProformaReportSnapshot } from "../snapshot-builders";
* - Sin IO
*/
export class ProformaDocumentPropertiesFactory
implements IDocumentPropertiesFactory<ProformaReportSnapshot>
implements IDocumentPropertiesFactory<ProformaReportSnapshot, ProformaDocumentGenerationContext>
{
build(snapshot: ProformaReportSnapshot): IDocumentProperties {
build(
snapshot: ProformaReportSnapshot,
context: ProformaDocumentGenerationContext
): IDocumentProperties {
return {
title: snapshot.reference ?? snapshot.proforma_reference,
subject: "proforma",
author: snapshot.company.legalName,
author: context.companyLegalName,
creator: "FactuGES ERP",
};
}

View File

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import { PreviewProformaReportUseCase } from "./preview-proforma-report.use-case";
@ -41,3 +41,49 @@ test("busca proformas archivadas al generar la vista previa", async () => {
assert.equal(activeFinderCalls, 0);
assert.equal(includingArchivedFinderCalls, 1);
});
test("genera la vista previa con el contexto documental mínimo", async () => {
const companyId = UniqueID.generateNewID();
const proformaId = UniqueID.generateNewID();
let receivedContext: unknown;
const useCase = new PreviewProformaReportUseCase({
finder: { findProformaByIdIncludingArchived: async () => Result.ok({} as never) },
companyReportProfileFinder: {
findByCompanyId: async () =>
Result.ok(
Maybe.some({
slug: "rodax",
legalName: "Rodax Software",
locale: Maybe.some("es-ES"),
currencyCode: Maybe.some("EUR"),
} as never)
),
},
fullReadModelAssembler: { assemble: async () => Result.ok({} as never) },
fullSnapshotBuilder: {
toOutput: () => ({ language_code: "es", currency_code: "EUR" }) as never,
},
reportSnapshotBuilder: { toOutput: () => ({}) as never },
documentService: {
generate: async (_snapshot, context) => {
receivedContext = context;
return Result.ok({
payload: Buffer.from("<html />"),
filename: "proforma-P-1.html",
mimeType: "text/html",
});
},
} as never,
transactionManager: { complete: async (work) => work({} as never) },
});
const result = await useCase.execute({ companyId, proforma_id: proformaId.toString() });
assert.equal(result.isSuccess, true);
assert.deepEqual(receivedContext, {
companySlug: "rodax",
languageCode: "es",
companyLegalName: "Rodax Software",
});
});

View File

@ -91,7 +91,9 @@ export class PreviewProformaReportUseCase {
});
const documentResult = await this.deps.documentService.generate(reportSnapshot, {
companySlug: reportSnapshot.company.slug,
companySlug: companyProfileResult.data.unwrap().slug,
languageCode: localization.languageCode,
companyLegalName: companyProfileResult.data.unwrap().legalName,
});
if (documentResult.isFailure) {

View File

@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { Maybe, Result } from "@repo/rdx-utils";
import { ReportProformaPdfUseCase } from "./report-proforma-pdf.use-case";
@ -41,3 +41,59 @@ test("busca proformas archivadas al generar el PDF", async () => {
assert.equal(activeFinderCalls, 0);
assert.equal(includingArchivedFinderCalls, 1);
});
test("genera el PDF con el contexto documental mínimo", async () => {
const companyId = UniqueID.generateNewID();
const proformaId = UniqueID.generateNewID();
const reportSnapshot = {
id: proformaId.toString(),
company_id: companyId.toString(),
proforma_reference: "P-1",
} as never;
let receivedContext: unknown;
const useCase = new ReportProformaPdfUseCase({
finder: {
findProformaByIdIncludingArchived: async () => Result.ok({} as never),
},
companyReportProfileFinder: {
findByCompanyId: async () =>
Result.ok(
Maybe.some({
slug: "rodax",
legalName: "Rodax Software",
locale: Maybe.some("es-ES"),
currencyCode: Maybe.some("EUR"),
version: Maybe.some("3"),
} as never)
),
},
fullReadModelAssembler: { assemble: async () => Result.ok({} as never) },
fullSnapshotBuilder: {
toOutput: () => ({ language_code: "es", currency_code: "EUR" }) as never,
},
reportSnapshotBuilder: { toOutput: () => reportSnapshot },
documentService: {
generate: async (_snapshot, context) => {
receivedContext = context;
return Result.ok({
payload: Buffer.from("pdf"),
filename: "proforma-P-1.pdf",
mimeType: "application/pdf",
});
},
} as never,
transactionManager: { complete: async (work) => work({} as never) },
});
const result = await useCase.execute({ companyId, proforma_id: proformaId.toString() });
assert.equal(result.isSuccess, true);
assert.deepEqual(receivedContext, {
companySlug: "rodax",
languageCode: "es",
companyLegalName: "Rodax Software",
currencyCode: "EUR",
companyVersion: "3",
});
});

View File

@ -9,6 +9,7 @@ import type {
IProformaFinder,
IProformaFullReadModelAssembler,
ProformaDocumentGeneratorService,
ProformaPdfDocumentGenerationContext,
} from "../services";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
import type { IProformaReportSnapshotBuilder } from "../snapshot-builders/report";
@ -26,7 +27,7 @@ export class ReportProformaPdfUseCase {
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
documentService: ProformaDocumentGeneratorService<ProformaPdfDocumentGenerationContext>;
transactionManager: ITransactionManager;
}
) {}
@ -93,8 +94,11 @@ export class ReportProformaPdfUseCase {
});
const documentResult = await this.deps.documentService.generate(reportSnapshot, {
//localization,
company: companyProfile,
companySlug: companyProfile.slug,
languageCode: localization.languageCode,
companyLegalName: companyProfile.legalName,
currencyCode: localization.currencyCode,
companyVersion: companyProfile.version.getOrUndefined(),
});
if (documentResult.isFailure) {

View File

@ -6,6 +6,7 @@ import {
} from "@erp/core/api";
import {
type ProformaDocumentGenerationContext,
type ProformaDocumentGeneratorService,
ProformaDocumentPropertiesFactory,
type ProformaReportSnapshot,
@ -28,13 +29,15 @@ export class ProformaHtmlPreviewPipelineFactory {
const propertiesFactory = new ProformaDocumentPropertiesFactory();
const postProcessor = new DocumentPostProcessorChain([]);
return new DocumentGenerationService<ProformaReportSnapshot>({
renderer,
preProcessors: [],
postProcessor,
sideEffects: [],
metadataFactory,
propertiesFactory,
});
return new DocumentGenerationService<ProformaReportSnapshot, ProformaDocumentGenerationContext>(
{
renderer,
preProcessors: [],
postProcessor,
sideEffects: [],
metadataFactory,
propertiesFactory,
}
);
}
}

View File

@ -10,6 +10,7 @@ import {
import {
type ProformaDocumentGeneratorService,
ProformaDocumentPropertiesFactory,
type ProformaPdfDocumentGenerationContext,
type ProformaReportSnapshot,
} from "../../../../application";
import { ProformaPdfCachePreProcessor } from "../pre-processors";
@ -24,7 +25,9 @@ export interface ProformaPdfDocumentPipelineFactoryDeps {
}
export class ProformaPdfDocumentPipelineFactory {
static create(deps: ProformaPdfDocumentPipelineFactoryDeps): ProformaDocumentGeneratorService {
static create(
deps: ProformaPdfDocumentPipelineFactoryDeps
): ProformaDocumentGeneratorService<ProformaPdfDocumentGenerationContext> {
const preProcessors = [new ProformaPdfCachePreProcessor(deps.documentStorage)];
const renderer = new ProformaPdfDocumentRenderer(
deps.fastReportRenderer,
@ -37,7 +40,10 @@ export class ProformaPdfDocumentPipelineFactory {
new PersistProformaPdfSideEffect(deps.documentStorage),
];
return new DocumentGenerationService<ProformaReportSnapshot>({
return new DocumentGenerationService<
ProformaReportSnapshot,
ProformaPdfDocumentGenerationContext
>({
renderer,
preProcessors,
postProcessor,

View File

@ -6,18 +6,21 @@ import type {
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
import type {
ProformaDocumentGenerationContext,
ProformaReportSnapshot,
} from "../../../../../application";
type ProformaHtmlPreviewRenderParams = {
companySlug: string;
format: string;
languageCode: string;
type ProformaHtmlPreviewRenderParams = ProformaDocumentGenerationContext & {
format: "PDF" | "HTML";
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaHtmlPreviewRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
export class ProformaHtmlPreviewRenderer
implements IDocumentRenderer<ProformaReportSnapshot, ProformaHtmlPreviewRenderParams>
{
public constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver

View File

@ -6,18 +6,21 @@ import type {
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
import type {
ProformaPdfDocumentGenerationContext,
ProformaReportSnapshot,
} from "../../../../../application";
type ProformaPdfDocumentRenderParams = {
companySlug: string;
format: string;
languageCode: string;
type ProformaPdfDocumentRenderParams = ProformaPdfDocumentGenerationContext & {
format: "PDF" | "HTML";
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaPdfDocumentRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
export class ProformaPdfDocumentRenderer
implements IDocumentRenderer<ProformaReportSnapshot, ProformaPdfDocumentRenderParams>
{
public constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver

View File

@ -0,0 +1,117 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ProformaDocumentPropertiesFactory } from "../../../../application";
import { ProformaHtmlPreviewRenderer } from "../renderers/fastreport/proforma-html-preview-renderer";
import { ProformaPdfDocumentRenderer } from "../renderers/fastreport/proforma-pdf-document-renderer";
import { ProformaHtmlPreviewDocumentMetadataFactory } from "./proforma-html-preview-document-metadata-factory";
import { ProformaPdfDocumentMetadataFactory } from "./proforma-pdf-document-metadata-factory";
const snapshot = {
id: "proforma-id",
company_id: "company-id",
proforma_reference: "P-1",
status: "issued",
} as never;
test("construye metadata PDF desde snapshot y contexto tipado", () => {
const metadata = new ProformaPdfDocumentMetadataFactory().build(snapshot, {
companySlug: "rodax",
languageCode: "es",
companyLegalName: "Rodax Software",
currencyCode: "EUR",
companyVersion: "3",
});
assert.deepEqual(metadata, {
documentType: "proforma",
documentId: "proforma-id",
companyId: "company-id",
companySlug: "rodax",
format: "PDF",
languageCode: "es",
filename: "proforma-P-1.pdf",
storageKey: "proforma:company-id:proforma-id:issued:pdf:es:EUR:rodax:3",
});
});
test("construye metadata HTML sin propiedades inexistentes del snapshot", () => {
const metadata = new ProformaHtmlPreviewDocumentMetadataFactory().build(snapshot, {
companySlug: "rodax",
languageCode: "es",
companyLegalName: "Rodax Software",
});
assert.deepEqual(metadata, {
documentType: "proforma",
documentId: "proforma-id",
companyId: "company-id",
companySlug: "rodax",
format: "HTML",
languageCode: "es",
filename: "proforma-P-1.html",
});
});
test("construye las propiedades desde el contexto sin leer company del snapshot", () => {
const properties = new ProformaDocumentPropertiesFactory().build(snapshot, {
companySlug: "rodax",
languageCode: "es",
companyLegalName: "Rodax Software",
});
assert.deepEqual(properties, {
title: "P-1",
subject: "proforma",
author: "Rodax Software",
creator: "FactuGES ERP",
});
});
test("entrega companySlug y languageCode al resolver la plantilla en ambos renderers", async () => {
const resolvedTemplates: unknown[] = [];
const templateResolver = {
resolveTemplatePath: (params: unknown) => {
resolvedTemplates.push(params);
return "/tmp/proforma.frx";
},
};
const fastReportRenderer = {
render: async () => ({ payload: Buffer.from("document") }),
};
const renderParams = {
companySlug: "rodax",
languageCode: "es",
companyLegalName: "Rodax Software",
currencyCode: "EUR",
format: "PDF" as const,
filename: "proforma-P-1.pdf",
mimeType: "application/pdf",
properties: {},
};
await new ProformaPdfDocumentRenderer(
fastReportRenderer as never,
templateResolver as never
).render(snapshot, renderParams);
await new ProformaHtmlPreviewRenderer(
fastReportRenderer as never,
templateResolver as never
).render(snapshot, renderParams);
assert.deepEqual(resolvedTemplates, [
{
module: "customer-invoices",
companySlug: "rodax",
languageCode: "es",
templateFilename: "proforma.frx",
},
{
module: "customer-invoices",
companySlug: "rodax",
languageCode: "es",
templateFilename: "proforma.frx",
},
]);
});

View File

@ -1,18 +1,24 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../application";
import type {
ProformaDocumentGenerationContext,
ProformaReportSnapshot,
} from "../../../../application";
export class ProformaHtmlPreviewDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot>
implements IDocumentMetadataFactory<ProformaReportSnapshot, ProformaDocumentGenerationContext>
{
build(snapshot: ProformaReportSnapshot): IDocumentMetadata {
build(
snapshot: ProformaReportSnapshot,
context: ProformaDocumentGenerationContext
): IDocumentMetadata {
return {
documentType: "proforma",
documentId: snapshot.id,
companyId: snapshot.company_id,
companySlug: snapshot.company.slug,
companySlug: context.companySlug,
format: "HTML",
languageCode: snapshot.localization.languageCode,
languageCode: context.languageCode,
filename: `proforma-${snapshot.proforma_reference}.html`,
};
}

View File

@ -1,26 +1,32 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../application";
import type {
ProformaPdfDocumentGenerationContext,
ProformaReportSnapshot,
} from "../../../../application";
export class ProformaPdfDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot>
implements IDocumentMetadataFactory<ProformaReportSnapshot, ProformaPdfDocumentGenerationContext>
{
build(snapshot: ProformaReportSnapshot, context: Record<string, string>): IDocumentMetadata {
build(
snapshot: ProformaReportSnapshot,
context: ProformaPdfDocumentGenerationContext
): IDocumentMetadata {
return {
documentType: "proforma",
documentId: snapshot.id,
companyId: context.company_id,
companySlug: context.company.slug,
companyId: snapshot.company_id,
companySlug: context.companySlug,
format: "PDF",
languageCode: context.localization.languageCode,
languageCode: context.languageCode,
filename: `proforma-${snapshot.proforma_reference}.pdf`,
storageKey: this.buildStorageKey(snapshot),
storageKey: this.buildStorageKey(snapshot, context),
};
}
private buildStorageKey(
snapshot: ProformaReportSnapshot,
context: Record<string, string>
context: ProformaPdfDocumentGenerationContext
): string | undefined {
if (snapshot.status === "draft") {
return undefined;
@ -35,15 +41,13 @@ export class ProformaPdfDocumentMetadataFactory
snapshot.id,
snapshot.status,
"pdf",
context.localization.languageCode,
context.localization.currencyCode,
context.company.slug,
context.languageCode,
context.currencyCode,
context.companySlug,
];
const companyVersion = context.company.version.getOrUndefined();
if (companyVersion) {
keyParts.push(companyVersion);
if (context.companyVersion) {
keyParts.push(context.companyVersion);
}
return keyParts.join(":");