Nuevo sistema de carga de módulos + Generador de documetos

This commit is contained in:
David Arranz 2026-02-08 23:14:00 +01:00
parent dc204237b1
commit 2b3dfce72c
30 changed files with 548 additions and 399 deletions

View File

@ -3,14 +3,22 @@ import type { IModuleServer, ModuleParams } from "@erp/core/api";
import { logger } from "../logger";
import { initModels, registerModels } from "./model-loader";
import { getService, listServices, registerService } from "./service-registry";
import { getServiceScoped, listServices, registerService } from "./service-registry";
const registeredModules: Map<string, IModuleServer> = new Map();
const initializedModules = new Set<string>();
const visiting = new Set<string>(); // para detección de ciclos
const initializationOrder: string[] = []; // orden de init → para warmup
// Config opcional para warmup (valores por defecto seguros)
// Nuevo: control por fases
const setupDone = new Set<string>(); // para detección de ciclos
const visiting = new Set<string>();
const setupOrder: string[] = [];
// Internal store
const internalByModule: Map<string, Record<string, unknown>> = new Map();
// Tracking de dependencias realmente usadas por módulo
const usedDependenciesByModule: Map<string, Set<string>> = new Map();
// Warmup config (valores por defecto seguros)
const WARMUP_TIMEOUT_MS = Number(process.env.WARMUP_TIMEOUT_MS) || 10_000;
const WARMUP_STRICT =
String(process.env.WARMUP_STRICT ?? "false")
@ -18,138 +26,236 @@ const WARMUP_STRICT =
.trim() === "true";
/**
Registra un módulo del servidor en el registry.
Lanza error si el nombre ya existe.
- Registra un módulo del servidor en el registry.
- Lanza error si el nombre ya existe.
*/
export function registerModule(pkg: IModuleServer) {
if (!pkg?.name) {
throw new Error('❌ Invalid module: missing "name" property');
}
if (registeredModules.has(pkg.name)) {
if (!pkg?.name) throw new Error('❌ Invalid module: missing "name" property');
if (registeredModules.has(pkg.name))
throw new Error(`❌ Module "${pkg.name}" already registered`);
}
registeredModules.set(pkg.name, pkg);
logger.info(`📦 Module enqueued: "${pkg.name}"`, { label: "moduleRegistry" });
}
/**
Inicializa todos los módulos registrados (resolviendo dependencias),
luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
- Inicializa todos los módulos registrados (resolviendo dependencias),
- luego inicializa los modelos (Sequelize) en bloque y, por último, ejecuta warmups opcionales.
*/
export async function initModules(params: ModuleParams) {
// 1) SETUP
for (const name of registeredModules.keys()) {
await loadModule(name, params, []); // secuencial para logs deterministas
await setupModule(name, params, []); // secuencial para logs deterministas
}
await withPhase("global", "initModels", async () => {
await initModels(params);
});
// 2) MODELS global
await withPhase("global", "initModels", async () => await initModels(params));
// 3) START por orden de setup
for (const name of setupOrder) {
const pkg = registeredModules.get(name);
if (!pkg?.start) continue;
// Si aún hay rastros del anterior init/registerDependencies en algún módulo:
// - start() ≈ init() en el contrato nuevo
// - setup() ≈ registerDependencies() en el contrato nuevo
await withPhase(name, "start", async () => {
// Aquí YA se permite getService/getInternal
await Promise.resolve(
pkg.start?.({
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices, // opcional: útil en debugging
} as any)
);
});
}
// 4) WARMUP opcional
await warmupModules(params);
// 5) Validación dependencias usadas vs declaradas
validateModuleDependencies();
}
/**
Carga recursivamente un módulo y sus dependencias con detección de ciclos.
*/
async function loadModule(name: string, params: ModuleParams, stack: string[]) {
if (initializedModules.has(name)) return;
* SETUP recursivo: resuelve deps, ejecuta setup del módulo (antiguo "registerDependencies"),
* registra modelos/servicios/internal, y guarda orden.
*/
async function setupModule(name: string, params: ModuleParams, stack: string[]) {
if (setupDone.has(name)) return;
if (visiting.has(name)) {
// Ciclo detectado: construir traza legible
const cyclePath = [...stack, name].join(" -> ");
throw new Error(`❌ Cyclic dependency detected: ${cyclePath}`);
throw new Error(`❌ Cyclic dependency detected: ${[...stack, name].join(" -> ")}`);
}
const pkg = registeredModules.get(name);
if (!pkg) {
if (!pkg)
throw new Error(
`❌ Module "${name}" not found (required by: ${stack[stack.length - 1] ?? "root"})`
);
}
visiting.add(name);
stack.push(name);
// 1) Resolver dependencias primero (en orden)
const deps = pkg.dependencies || [];
for (const dep of deps) {
await loadModule(dep, params, stack.slice());
// 1) deps first
for (const dep of pkg.dependencies ?? []) {
await setupModule(dep, params, stack.slice());
}
// 2) Inicializar el módulo (permite async)
await withPhase(name, "init", async () => {
await Promise.resolve(pkg.init(params));
});
// 3) Registrar dependencias que expone (permite async)
const pkgApi = await withPhase(name, "registerDependencies", async () => {
// 2) setup phase (contrato nuevo) = registerDependencies (en tu código actual)
const pkgApi = await withPhase(name, "setup", async () => {
return await Promise.resolve(
pkg.registerDependencies?.({
pkg.setup?.({
...params,
// En setup se permite getService para wiring.
getService: makeGetService(name, pkg),
listServices,
getService,
})
} as any)
);
});
// 4) Registrar modelos de Sequelize, si existen
// 3) internal store
if (pkgApi?.internal) {
internalByModule.set(name, pkgApi.internal as Record<string, unknown>);
} else {
internalByModule.set(name, {});
}
// 4) models
if (pkgApi?.models) {
await withPhase(name, "registerModels", async () => {
await Promise.resolve(registerModels(pkgApi.models, params, { moduleName: pkg.name }));
await Promise.resolve(registerModels(pkgApi.models, params, { moduleName: name }));
});
}
// 5) Registrar servicios, si existen
if (pkgApi?.services && typeof pkgApi.services === "object") {
// 5) services (namespaced)
if (pkgApi?.services) {
await withPhase(name, "registerServices", async () => {
await Promise.resolve(registerService(pkg.name, pkgApi.services));
for (const [serviceKey, serviceApi] of Object.entries(pkgApi.services!)) {
registerService(`${name}:${serviceKey}`, serviceApi);
}
});
}
initializedModules.add(name);
initializationOrder.push(name); // recordamos el orden para warmup
setupDone.add(name);
setupOrder.push(name);
visiting.delete(name);
stack.pop();
logger.info(`✅ Module "${name}" registered`, { label: "moduleRegistry" });
logger.info(`✅ Module "${name}" setup done`, { label: "moduleRegistry" });
}
/** getService instrumentado + scoped */
function makeGetService(moduleName: string, pkg: IModuleServer) {
return <T>(serviceName: string): T => {
const [serviceModule] = serviceName.split(":");
trackDependencyUse(moduleName, serviceModule);
// IMPORTANTE: devolver el valor
return getServiceScoped<T>(moduleName, pkg.dependencies ?? [], serviceName);
};
}
/** getInternal: por defecto solo permite al propio módulo acceder a su internal */
function makeGetInternal(requesterModule: string) {
return <T>(moduleName: string, key?: string): T => {
if (moduleName !== requesterModule) {
throw new Error(
`Module "${requesterModule}" attempted to access internal of "${moduleName}". ` +
"Internal is private to the owning module."
);
}
const internal = internalByModule.get(moduleName) ?? {};
if (!key) return internal as unknown as T;
if (!(key in internal)) {
throw new Error(`Internal key "${key}" not found in module "${moduleName}".`);
}
return internal[key] as T;
};
}
function trackDependencyUse(requester: string, dep: string) {
let set = usedDependenciesByModule.get(requester);
if (!set) {
set = new Set();
usedDependenciesByModule.set(requester, set);
}
set.add(dep);
}
function validateModuleDependencies() {
for (const [moduleName, pkg] of registeredModules.entries()) {
const declared = new Set(pkg.dependencies ?? []);
const used = usedDependenciesByModule.get(moduleName) ?? new Set<string>();
// ❌ usadas pero no declaradas
const undeclaredUsed = [...used].filter((d) => !declared.has(d));
if (undeclaredUsed.length > 0) {
throw new Error(
`❌ Module "${moduleName}" used undeclared dependencies: ${undeclaredUsed.join(", ")}`
);
}
// ⚠️ declaradas pero no usadas (opcional)
const unusedDeclared = [...declared].filter((d) => !used.has(d));
if (unusedDeclared.length > 0) {
logger.warn(
`⚠️ Module "${moduleName}" declared unused dependencies: ${unusedDeclared.join(", ")}`,
{ label: "moduleRegistry", module: moduleName }
);
}
}
}
/**
Ejecuta warmup() opcional de cada módulo en orden de inicialización.
Si WARMUP_STRICT=true, un fallo aborta el arranque; si no, se avisa y continúa.
- Ejecuta warmup() opcional de cada módulo en orden de inicialización.
- Si WARMUP_STRICT=true, un fallo aborta el arranque; si no, se avisa y continúa.
*/
async function warmupModules(params: ModuleParams) {
logger.info("🌡️ Warmup: starting...", { label: "moduleRegistry" });
for (const name of initializationOrder) {
for (const name of setupOrder) {
const pkg = registeredModules.get(name);
if (!pkg) continue;
if (!pkg?.warmup) continue;
const maybeWarmup = (pkg as unknown as { warmup?: (p: ModuleParams) => Promise<void> | void })
.warmup;
const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise<void> | void);
if (typeof maybeWarmup !== "function") continue;
if (typeof maybeWarmup === "function") {
try {
await withPhase(name, "warmup", () =>
withTimeout(Promise.resolve(maybeWarmup(params)), WARMUP_TIMEOUT_MS, `${name}.warmup`)
);
} catch (error) {
if (WARMUP_STRICT) {
logger.error("⛔️ Warmup failed (strict=true). Aborting.", {
label: "moduleRegistry",
module: name,
error,
});
throw error;
}
logger.warn("⚠️ Warmup failed but continuing (strict=false)", {
try {
await withPhase(name, "warmup", () =>
withTimeout(
Promise.resolve(
maybeWarmup({
...params,
getService: makeGetService(name, pkg),
getInternal: makeGetInternal(name),
listServices,
})
),
WARMUP_TIMEOUT_MS,
`${name}.warmup`
)
);
} catch (error) {
if (WARMUP_STRICT) {
logger.error("⛔️ Warmup failed (strict=true). Aborting.", {
label: "moduleRegistry",
module: name,
error,
});
throw error;
}
logger.warn("⚠️ Warmup failed but continuing (strict=false)", {
label: "moduleRegistry",
module: name,
error,
});
}
}
@ -157,17 +263,11 @@ async function warmupModules(params: ModuleParams) {
}
/**
Helper para anotar fase y módulo en logs (inicio/fin/duración) y errores.
Helper para anotar fase y módulo en logs (inicio/fin/duración) y errores.
*/
async function withPhase<T>(
moduleName: string,
phase:
| "init"
| "registerDependencies"
| "registerModels"
| "registerServices"
| "initModels"
| "warmup",
phase: "setup" | "start" | "registerModels" | "registerServices" | "initModels" | "warmup",
fn: () => Promise<T> | T
): Promise<T> {
const startedAt = Date.now();

View File

@ -10,10 +10,32 @@ export function registerService(name: string, api: any) {
services[name] = api;
}
/**
* Recupera un servicio registrado bajo un "scope".
* getService("customers:repository")
* Debe declarar: dependencies: ["customers"]
*/
export function getServiceScoped<T = any>(
requesterModule: string,
allowedDeps: readonly string[],
name: string
): T {
const [serviceModule] = name.split(":");
if (!allowedDeps.includes(serviceModule)) {
throw new Error(
`❌ Module "${requesterModule}" tried to access service "${name}" ` +
`without declaring dependency on "${serviceModule}"`
);
}
return getService<T>(name);
}
/**
* Recupera un servicio registrado, con tipado opcional.
*/
export function getService<T = any>(name: string): T {
function getService<T = any>(name: string): T {
const service = services[name];
if (!service) {
throw new Error(`❌ Servicio "${name}" no encontrado.`);

View File

@ -172,7 +172,7 @@
"useYield": "error"
},
"complexity": {
"noBannedTypes": "error",
"noBannedTypes": "off",
"noExcessiveCognitiveComplexity": {
"level": "warn",
"options": {

1
input.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -20,6 +20,9 @@ import type { IDocumentSideEffect } from "./document-side-effect.interface";
* 4. Post-processors (firma, watermark, etc.)
* 5. Side-effects (persistencia, métricas) [best-effort]
*/
export type DocumentGenerationServiceRenderParams = Record<string, string>;
export class DocumentGenerationService<TSnapshot> {
constructor(
private readonly metadataFactory: IDocumentMetadataFactory<TSnapshot>,
@ -29,7 +32,10 @@ export class DocumentGenerationService<TSnapshot> {
private readonly sideEffects: readonly IDocumentSideEffect[]
) {}
async generate(snapshot: TSnapshot): Promise<Result<IDocument, DocumentGenerationError>> {
async generate(
snapshot: TSnapshot,
params: DocumentGenerationServiceRenderParams
): Promise<Result<IDocument, DocumentGenerationError>> {
let metadata: IDocumentMetadata;
// 1. Metadata
@ -55,6 +61,7 @@ export class DocumentGenerationService<TSnapshot> {
let document: IDocument;
try {
document = await this.renderer.render(snapshot, {
...params,
format: metadata.format,
languageCode: metadata.languageCode,
filename: metadata.filename,

View File

@ -1,4 +1,4 @@
import type { IDocument, IDocumentMetadata } from "../application-models";
import type { IDocument } from "../application-models";
export interface IDocumentStorage {
/**
@ -8,5 +8,5 @@ export interface IDocumentStorage {
* - Best-effort
* - Nunca lanza (errores se gestionan internamente)
*/
save(document: IDocument, metadata: IDocumentMetadata): Promise<void>;
save(document: IDocument, metadata: Record<string, unknown>): Promise<void>;
}

View File

@ -2,4 +2,3 @@ export * from "./renderer";
export * from "./renderer.interface";
export * from "./renderer-registry";
export * from "./renderer-registry.interface";
export * from "./renderer-template-resolver.interface";

View File

@ -1,4 +0,0 @@
export interface IRendererTemplateResolver {
/** Devuelve la ruta absoluta del fichero de plantilla */
resolveTemplatePath(module: string, companySlug: string, templateName: string): string;
}

View File

@ -3,16 +3,20 @@ import {
FastReportExecutableResolver,
FastReportProcessRunner,
FastReportRenderer,
FastReportTemplateResolver,
FilesystemDocumentCacheStore,
RestDocumentSigningService,
} from "../documents";
import { FilesystemDocumentStorage } from "../storage";
export function buildCoreDocumentsDI(env: NodeJS.ProcessEnv) {
export const buildCoreDocumentsDI = (env: NodeJS.ProcessEnv) => {
const { TEMPLATES_PATH } = env;
// Renderers
const frExecutableResolver = new FastReportExecutableResolver(env.FASTREPORT_BIN);
const frProcessRunner = new FastReportProcessRunner();
const fastReportRenderer = new FastReportRenderer(frExecutableResolver, frProcessRunner);
const fastReportTemplateResolver = new FastReportTemplateResolver(TEMPLATES_PATH!);
// Signing
const signingContextResolver = new EnvCompanySigningContextResolver(env);
@ -32,6 +36,7 @@ export function buildCoreDocumentsDI(env: NodeJS.ProcessEnv) {
return {
documentRenderers: {
fastReportRenderer,
fastReportTemplateResolver,
},
documentSigning: {
signingService,
@ -42,4 +47,4 @@ export function buildCoreDocumentsDI(env: NodeJS.ProcessEnv) {
storage,
},
};
}
};

View File

@ -0,0 +1,59 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { FastReportTemplateNotFoundError } from "./fastreport-errors";
/**
* Resuelve rutas de plantillas para desarrollo y producción.
*/
export class FastReportTemplateResolver {
constructor(protected readonly rootPath: string) {}
/** Une partes de ruta relativas al rootPath */
protected resolveJoin(parts: string[]): string {
return join(this.rootPath, ...parts);
}
/**
* Devuelve el directorio donde residen las plantillas de un módulo/empresa
* según el entorno (dev/prod).
*/
protected resolveTemplateDirectory(module: string, companySlug: string): string {
const isDev = process.env.NODE_ENV === "development";
if (isDev) {
// <root>/<module>/templates/<companySlug>/
return this.resolveJoin([module, "templates", companySlug]);
}
// <root>/templates/<module>/<companySlug>/
//return this.resolveJoin(["templates", module, companySlug]);
return this.resolveJoin([module]);
}
/** Resuelve una ruta de recurso relativa al directorio de plantilla */
protected resolveAssetPath(templateDir: string, relative: string): string {
return join(templateDir, relative);
}
/**
* Devuelve la ruta absoluta del fichero de plantilla.
*/
public resolveTemplatePath(module: string, companySlug: string, templateName: string): string {
const dir = this.resolveTemplateDirectory(module, companySlug);
const filePath = this.resolveAssetPath(dir, templateName);
if (!existsSync(filePath)) {
throw new FastReportTemplateNotFoundError(
`Template not found: module=${module} company=${companySlug} name=${templateName}`
);
}
return filePath;
}
/** Lee el contenido de un fichero plantilla */
protected readTemplateFile(templatePath: string): string {
return readFileSync(templatePath, "utf8");
}
}

View File

@ -3,3 +3,4 @@ export * from "./fastreport-executable-resolver";
export * from "./fastreport-process-runner";
export * from "./fastreport-render-options.type";
export * from "./fastreport-renderer";
export * from "./fastreport-template-resolver";

View File

@ -1,9 +1,34 @@
//Contrato para los Modules backend (Node.js)
import { ModuleMetadata } from "../../common";
import { ModuleDependencies, ModuleParams } from "./types";
import type { ModuleMetadata } from "../../common";
import type { ModuleSetupResult, SetupParams, StartParams, WarmupParams } from "./types";
export interface IModuleServer extends ModuleMetadata {
init(params: ModuleParams): Promise<void>;
registerDependencies?(params: ModuleParams): Promise<ModuleDependencies>;
name: string;
version: string;
description?: string;
/**
* Módulos de los que depende este módulo.
* Deben existir y cargarse antes.
*/
dependencies?: string[];
/**
* Fase de construcción del módulo.
* Aquí se construye el dominio y se decide qué se expone.
*/
setup?: (params: SetupParams) => Promise<ModuleSetupResult> | ModuleSetupResult;
/**
* Fase de arranque del módulo.
* Aquí se conecta el módulo al runtime.
*/
start?: (params: StartParams) => Promise<void> | void;
/**
* Warmup opcional (IO, precargas, validaciones externas).
*/
warmup?: (params: WarmupParams) => Promise<void> | void;
}

View File

@ -15,3 +15,29 @@ export interface ModuleDependencies {
models?: any[];
services?: { [key: string]: any };
}
export type ModuleSetupResult = {
models?: any[];
services?: Record<string, unknown>;
internal?: Record<string, unknown>;
};
export type SetupParams = ModuleParams & {
registerService: (name: string, api: unknown) => void;
};
export type StartParams = ModuleParams & {
/**
* Acceso a servicios expuestos por otros módulos.
* Todas las dependencias ya están resueltas.
*/
getService: <T>(serviceName: string) => T;
/**
* Acceso a internal del propio módulo.
* No debe usarse para consumir otros módulos.
*/
getInternal: <T>(moduleName: string, key?: string) => T;
};
export type WarmupParams = StartParams;

View File

@ -7,12 +7,14 @@ export interface IssuedInvoiceReportSnapshot {
invoice_number: string;
series: string;
status: string;
reference: string;
language_code: string;
currency_code: string;
invoice_date: string;
payment_method: string;
notes: string;
recipient: {
name: string;

View File

@ -1,6 +1,6 @@
import type { ITransactionManager } from "@erp/core/api";
import type { IIssuedInvoiceDocumentReportService, IIssuedInvoiceFinder } from "../services";
import type { IIssuedInvoiceFinder, IssuedInvoiceDocumentGeneratorService } from "../services";
import type { IIssuedInvoiceListItemSnapshotBuilder } from "../snapshot-builders";
import type { IIssuedInvoiceFullSnapshotBuilder } from "../snapshot-builders/full";
import type { IIssuedInvoiceReportSnapshotBuilder } from "../snapshot-builders/report";
@ -38,7 +38,7 @@ export function buildReportIssuedInvoiceUseCase(deps: {
finder: IIssuedInvoiceFinder;
fullSnapshotBuilder: IIssuedInvoiceFullSnapshotBuilder;
reportSnapshotBuilder: IIssuedInvoiceReportSnapshotBuilder;
documentService: IIssuedInvoiceDocumentReportService;
documentService: IssuedInvoiceDocumentGeneratorService;
transactionManager: ITransactionManager;
}) {
return new ReportIssuedInvoiceUseCase(

View File

@ -2,4 +2,3 @@ export * from "./issued-invoice-document-generator.interface";
export * from "./issued-invoice-document-metadata-factory";
export * from "./issued-invoice-document-renderer.interface";
export * from "./issued-invoice-finder";
export * from "./issued-invoice-report-snapshot-builder";

View File

@ -14,7 +14,7 @@ export class IssuedInvoiceDocumentMetadataFactory
{
build(snapshot: IssuedInvoiceReportSnapshot): IDocumentMetadata {
if (!snapshot.id) {
throw new Error("IssuedInvoiceReportSnapshot.invoiceId is required");
throw new Error("IssuedInvoiceReportSnapshot.id is required");
}
if (!snapshot.company_id) {

View File

@ -40,6 +40,7 @@ export class IssuedInvoiceReportSnapshotBuilder implements IIssuedInvoiceReportS
invoice_number: snapshot.invoice_number,
series: snapshot.series,
status: snapshot.status,
reference: snapshot.reference,
language_code: snapshot.language_code,
currency_code: snapshot.currency_code,
@ -47,6 +48,7 @@ export class IssuedInvoiceReportSnapshotBuilder implements IIssuedInvoiceReportS
invoice_date: DateHelper.format(snapshot.invoice_date, locale),
payment_method: snapshot.payment_method?.payment_description ?? "",
notes: snapshot.notes,
recipient: {
name: snapshot.recipient.name,

View File

@ -1,11 +1,8 @@
import type { DTO } from "@erp/core";
import type { ITransactionManager, RendererFormat } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CustomerInvoice } from "../../../../domain";
import type { IssuedInvoiceDocumentGeneratorService } from "../../../../infrastructure/documents";
import type { IIssuedInvoiceFinder } from "../../services";
import type { IIssuedInvoiceFinder, IssuedInvoiceDocumentGeneratorService } from "../../services";
import type { IIssuedInvoiceFullSnapshotBuilder } from "../../snapshot-builders";
import type { IIssuedInvoiceReportSnapshotBuilder } from "../../snapshot-builders/report";
@ -59,10 +56,8 @@ export class ReportIssuedInvoiceUseCase {
});
// Llamar al servicio y que se apañe
const documentResult = await this.documentGenerationService.generate({
companyId,
invoiceId,
snapshot: reportSnapshot,
const documentResult = await this.documentGenerationService.generate(reportSnapshot, {
companySlug: "rodax",
});
if (documentResult.isFailure) {
@ -79,92 +74,5 @@ export class ReportIssuedInvoiceUseCase {
return Result.fail(error as Error);
}
});
// Cargar factura emitida en transacción corta (solo lectura)
const invoiceOrError = await this.transactionManager.complete(async (transaction) => {
return this.invoiceRepository.findIssuedInvoiceByIdInCompany(
input.companyId,
invoiceId,
transaction
);
});
if (invoiceOrError.isFailure) {
return Result.fail(invoiceOrError.error);
}
const issuedInvoice = invoiceOrError.data;
// Delegar completamente la generación del documento legal (snapshot → render → firma → cache)
try {
const document = await this.issuedInvoiceDocumentService.generate(issuedInvoice);
return Result.ok(document);
} catch (error) {
// Errores no esperados (infra / firma / filesystem)
// Se dejan subir como fallo del caso de uso
return Result.fail(error as Error);
}
const fullPresenter = this.presenterRegistry.getPresenter<CustomerInvoice>({
resource: "issued-invoice",
projection: "FULL",
});
const reportPresenter = this.presenterRegistry.getPresenter<DTO>({
resource: "issued-invoice",
projection: "REPORT",
});
const renderer = this.rendererRegistry.getRenderer<DTO>({
resource: "issued-invoice",
format,
});
return this.transactionManager.complete(async (transaction) => {
try {
const invoiceOrError = await this.service.getIssuedInvoiceByIdInCompany(
companyId,
invoiceId,
transaction
);
if (invoiceOrError.isFailure) {
return Result.fail(invoiceOrError.error);
}
const invoice = invoiceOrError.data;
const documentId = `${invoice.series.getOrUndefined()}${invoice.invoiceNumber.toString()}`;
const invoiceDTO = await fullPresenter.toOutput(invoice, { companySlug });
const reportInvoiceDTO = await reportPresenter.toOutput(invoiceDTO, { companySlug });
const result = (await renderer.render(reportInvoiceDTO, {
companySlug,
documentId,
})) as unknown;
if (result.isFailure) {
return Result.fail(result.error);
}
const { payload: invoiceRendered } = result.data;
if (format === "HTML") {
return Result.ok({
data: String(invoiceRendered),
filename: undefined,
});
}
return Result.ok({
data: invoiceRendered as Buffer<ArrayBuffer>,
filename: `customer-invoice-${invoice.invoiceNumber}.pdf`,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -1,29 +0,0 @@
import { buildCoreDocumentsDI } from "@erp/core/api";
import { httpClient } from "./http-client";
import { buildIssuedInvoicesDependencies } from "./infrastructure";
import { buildTransactionManager } from "./infrastructure/di/repositories.di";
const coreInfra = buildCoreDocumentsDI({
fastReport: {
templatesBasePath: "/var/app/templates",
},
signing: {
endpoint: "http://localhost:8000",
httpClient,
},
storage: {
basePath: "/var/app/reports",
},
});
const transactionManager = buildTransactionManager(database);
const issuedInvoicesModule = buildIssuedInvoicesDependencies({
fastReport: coreInfra.fastReport,
documentSigningService: coreInfra.documentSigningService,
certificateResolver: coreInfra.certificateResolver,
transactionManager,
});
export const reportIssuedInvoiceUseCase = issuedInvoicesModule.reportIssuedInvoiceUseCase;

View File

@ -1,68 +1,89 @@
import type { IModuleServer, ModuleParams } from "@erp/core/api";
import type { IModuleServer } from "@erp/core/api";
import { models } from "./infrastructure";
import { issuedInvoicesRouter } from "./infrastructure/express/issued-invoices/issued-invoices.routes";
import {
type IssuedInvoicesInternalDeps,
buildIssuedInvoicesDependencies,
buildIssuedInvoicesServices,
issuedInvoicesRouter,
models,
} from "./infrastructure";
export const customerInvoicesAPIModule: IModuleServer = {
name: "customer-invoices",
version: "1.0.0",
dependencies: ["customers"],
async init(params: ModuleParams) {
const { logger, env } = params;
/**
* Fase de SETUP
* ----------------
* - Construye el dominio (una sola vez)
* - Define qué expone el módulo
* - NO conecta infraestructura
*/
async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
//proformasRouter(params);
// 1) Dominio interno
const issuedInvoicesInternalDeps = buildIssuedInvoicesDependencies(params);
//const proformasInternalDeps = buildProformasDependencies(params);
issuedInvoicesRouter({
...params,
services: issuedInvoicesServices,
});
// 2) Servicios públicos (Application Services)
const issuedInvoicesServices = buildIssuedInvoicesServices(issuedInvoicesInternalDeps);
//const proformasServices = buildProformasServices(proformasInternalDeps);
logger.info("🚀 CustomerInvoices module initialized", { label: this.name });
},
async registerDependencies(
params: ModuleParams & {
getService: (name: string) => any;
}
) {
const { logger, getService } = params;
// -----------------------------------------------------------------------
// 1⃣ Obtener dependencias CORE
// -----------------------------------------------------------------------
const coreDocuments = getService("core.documents");
const transactionManager = getService("core.transactionManager");
if (!coreDocuments) {
throw new Error("core.documents service not available");
}
// -----------------------------------------------------------------------
// 2⃣ Construir DI del módulo
// -----------------------------------------------------------------------
issuedInvoicesServices = buildIssuedInvoicesDI({
coreDocuments,
transactionManager,
});
// -----------------------------------------------------------------------
// 3⃣ Exponer servicios del módulo
// -----------------------------------------------------------------------
logger.info("🚀 CustomerInvoices module dependencies registered", {
label: this.name,
});
logger.info("🚀 CustomerInvoices module dependencies registered", { label: this.name });
return {
// Modelos Sequelize del módulo
models,
// Servicios expuestos a otros módulos
services: {
issuedInvoices: issuedInvoicesServices,
//proformas: proformasServices
},
// Implementación privada del módulo
internal: {
issuedInvoices: issuedInvoicesInternalDeps,
//proformas: proformasInternalDeps
},
};
},
/**
* Fase de START
* -------------
* - Conecta el módulo al runtime
* - Puede usar servicios e internals ya construidos
* - NO construye dominio
*/
async start(params) {
const { app, baseRoutePath, logger, getInternal } = params;
// Recuperamos el dominio interno del módulo
const issuedInvoicesInternalDeps = getInternal<IssuedInvoicesInternalDeps>(
"customer-invoices",
"issuedInvoices"
);
//const proformasInternalDeps = getInternal("customer-invoices", "proformas");
// Registro de rutas HTTP
issuedInvoicesRouter(params, issuedInvoicesInternalDeps);
//proformasRouter(params, proformasInternalDeps);
logger.info("🚀 CustomerInvoices module started", {
label: this.name,
});
},
/**
* Warmup opcional (si lo necesitas en el futuro)
* ----------------------------------------------
* warmup(params) {
* ...
* }
*/
};
export default customerInvoicesAPIModule;

View File

@ -1,30 +1,12 @@
import type {
FastReportRenderer,
IDocumentCacheStore,
IDocumentSigningService,
IDocumentStorage,
ISigningContextResolver,
} from "@erp/core/api";
import { buildCoreDocumentsDI } from "@erp/core/api";
import {
IssuedInvoiceDocumentPipelineFactory,
type IssuedInvoiceDocumentPipelineFactoryDeps,
} from "../documents";
export const buildIssuedInvoiceDocumentService = (deps: {
documentRenderers: {
fastReportRenderer: FastReportRenderer;
};
documentSigning: {
signingService: IDocumentSigningService;
signingContextResolver: ISigningContextResolver;
};
documentStorage: {
cacheStore: IDocumentCacheStore;
storage: IDocumentStorage;
};
}) => {
const { documentRenderers, documentSigning, documentStorage } = deps;
export const buildIssuedInvoiceDocumentService = (env: NodeJS.ProcessEnv) => {
const { documentRenderers, documentSigning, documentStorage } = buildCoreDocumentsDI(env);
const pipelineDeps: IssuedInvoiceDocumentPipelineFactoryDeps = {
fastReportRenderer: documentRenderers.fastReportRenderer,
@ -36,6 +18,8 @@ export const buildIssuedInvoiceDocumentService = (deps: {
//
documentCacheStore: documentStorage.cacheStore,
documentStorage: documentStorage.storage,
templateResolver: documentRenderers.fastReportTemplateResolver,
};
const documentGeneratorPipeline = IssuedInvoiceDocumentPipelineFactory.create(pipelineDeps);

View File

@ -1,2 +1,2 @@
export * from "./issued-invoices.di";
//export * from "./proformas.di";
export * from "./issued-invoices-services";

View File

@ -0,0 +1,26 @@
import type { IssuedInvoicesInternalDeps } from "./issued-invoices.di";
export type IssuedInvoicesServiceslDeps = {
services: {
listIssuedInvoices: (filters: unknown, context: unknown) => null;
getIssuedInvoiceById: (id: unknown, context: unknown) => null;
generateIssuedInvoiceReport: (id: unknown, options: unknown, context: unknown) => null;
};
};
export function buildIssuedInvoicesServices(
deps: IssuedInvoicesInternalDeps
): IssuedInvoicesServiceslDeps {
return {
services: {
listIssuedInvoices: (filters, context) => null,
//internal.useCases.listIssuedInvoices().execute(filters, context),
getIssuedInvoiceById: (id, context) => null,
//internal.useCases.getIssuedInvoiceById().execute(id, context),
generateIssuedInvoiceReport: (id, options, context) => null,
//internal.useCases.reportIssuedInvoice().execute(id, options, context),
},
};
}

View File

@ -1,12 +1,6 @@
// modules/invoice/infrastructure/invoice-dependencies.factory.ts
import {
type IDocumentSigningService,
type ITransactionManager,
type ModuleParams,
buildCoreDocumentsDI,
buildTransactionManager,
} from "@erp/core/api";
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import {
type GetIssuedInvoiceByIdUseCase,
@ -18,114 +12,53 @@ import {
buildListIssuedInvoicesUseCase,
buildReportIssuedInvoiceUseCase,
} from "../../application/issued-invoices";
import { IssuedInvoiceDocumentPipelineFactory } from "../documents";
import { buildIssuedInvoiceDocumentService } from "./documents.di";
import { buildRepository } from "./repositories.di";
export type IssuedInvoicesDeps = {
export type IssuedInvoicesInternalDeps = {
useCases: {
list_issued_invoices: () => ListIssuedInvoicesUseCase;
get_issued_invoice_by_id: () => GetIssuedInvoiceByIdUseCase;
report_issued_invoice: () => ReportIssuedInvoiceUseCase;
listIssuedInvoices: () => ListIssuedInvoicesUseCase;
getIssuedInvoiceById: () => GetIssuedInvoiceByIdUseCase;
reportIssuedInvoice: () => ReportIssuedInvoiceUseCase;
};
};
export function buildIssuedInvoicesDI(params: ModuleParams) {
export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInvoicesInternalDeps {
const { database, env } = params;
const { documentRenderers, documentSigning, documentStorage } = buildCoreDocumentsDI(env);
// Infrastructure
const transactionManager = buildTransactionManager(database);
const repository = buildRepository(database);
// --------------------------------------------------------------------------
// 4⃣ Finder + snapshot builders (APPLICATION)
// --------------------------------------------------------------------------
// Application helpers
const finder = buildIssuedInvoiceFinder(repository);
const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders();
const documentGeneratorPipeline = buildIssuedInvoiceDocumentService(env);
const issuedInvoiceDocumentPipeline = new IssuedInvoiceDocumentPipelineFactory({
renderer: documentRenderers.fastReportRenderer,
}).create;
// --------------------------------------------------------------------------
// 5⃣ Use Case
// --------------------------------------------------------------------------
// Internal use cases (factories)
return {
useCases: {
list_issued_invoices: () =>
listIssuedInvoices: () =>
buildListIssuedInvoicesUseCase({
finder,
itemSnapshotBuilder: snapshotBuilders.list,
transactionManager,
}),
get_issued_invoice_by_id: () =>
getIssuedInvoiceById: () =>
buildGetIssuedInvoiceByIdUseCase({
finder,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
report_issued_invoice: () =>
reportIssuedInvoice: () =>
buildReportIssuedInvoiceUseCase({
finder,
fullSnapshotBuilder: snapshotBuilders.full,
reportSnapshotBuilder: snapshotBuilders.report,
documentService: issuedInvoiceDocumentService,
transactionManager,
}),
},
};
return {
reportIssuedInvoiceUseCase,
};
}
export function buildIssuedInvoicesDependencies(deps: {
documentSigningService: IDocumentSigningService;
certificateResolver: ICertificateResolver;
transactionManager: ITransactionManager;
}): IssuedInvoicesDeps {
const { database, env } = params;
const templateRootPath = env.TEMPLATES_PATH;
const documentRootPath = env.DOCUMENTS_PATH;
/** Infraestructura */
const repository = buildRepository(database);
//
const snapshotBuilders = buildIssuedInvoiceSnapshotBuilders();
const finder = buildIssuedInvoiceFinder(repository);
const renderers = buildIssuedInvoiceReportRenderers(templateRootPath, documentRootPath);
const reportService = buildIssuedInvoiceReportService(
renderers.fastReportPDFRenderer,
signingService,
certificateResolver
);
return {
useCases: {
list_issued_invoices: () =>
buildListIssuedInvoicesUseCase({
finder,
itemSnapshotBuilder: snapshotBuilders.list,
transactionManager,
}),
get_issued_invoice_by_id: () =>
buildGetIssuedInvoiceByIdUseCase({
finder,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
report_issued_invoice: () =>
buildReportIssuedInvoiceUseCase({
finder,
fullSnapshotBuilder: snapshotBuilders.full,
reportSnapshotBuilder: snapshotBuilders.report,
documentService: renderers.reportRenderer,
documentService: documentGeneratorPipeline,
transactionManager,
}),
},

View File

@ -2,6 +2,7 @@ import {
DocumentGenerationService,
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
type IDocumentCacheStore,
type IDocumentPostProcessor,
type IDocumentSideEffect,
@ -28,7 +29,7 @@ import { PersistIssuedInvoiceDocumentSideEffect } from "../side-effects";
export interface IssuedInvoiceDocumentPipelineFactoryDeps {
// Core / Infra
fastReportRenderer: FastReportRenderer;
//templateResolver: IDocumentTemplateResolver;
templateResolver: FastReportTemplateResolver;
signingContextResolver: ISigningContextResolver;
documentSigningService: IDocumentSigningService;
@ -49,7 +50,7 @@ export class IssuedInvoiceDocumentPipelineFactory {
// 2. Renderer (FastReport)
const documentRenderer = new IssuedInvoiceDocumentRenderer(
deps.fastReportRenderer,
"/templates/issued-invoice.frx"
deps.templateResolver
);
// 3) Metadata factory (Application)

View File

@ -1,4 +1,9 @@
import type { FastReportRenderer, IDocument, IDocumentRenderer } from "@erp/core/api";
import type {
FastReportRenderer,
FastReportTemplateResolver,
IDocument,
IDocumentRenderer,
} from "@erp/core/api";
import type { IssuedInvoiceReportSnapshot } from "../../../../application";
@ -12,17 +17,32 @@ import type { IssuedInvoiceReportSnapshot } from "../../../../application";
*
* NO captura errores: FastReportError se propaga.
*/
export type IssuedInvoiceDocumentRenderParams = {
companySlug: string;
};
export class IssuedInvoiceDocumentRenderer
implements IDocumentRenderer<IssuedInvoiceReportSnapshot>
{
constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templatePath: string
private readonly templateResolver: FastReportTemplateResolver
) {}
async render(snapshot: IssuedInvoiceReportSnapshot): Promise<IDocument> {
async render(
snapshot: IssuedInvoiceReportSnapshot,
params: IssuedInvoiceDocumentRenderParams
): Promise<IDocument> {
const { companySlug } = params;
const templatePath = this.templateResolver.resolveTemplatePath(
"customer-invoices",
companySlug,
"issued-invoice.frx"
);
const output = await this.fastReportRenderer.render({
templatePath: this.templatePath,
templatePath,
inputData: snapshot,
format: "PDF",
});

View File

@ -10,13 +10,13 @@ import {
ReportIssueInvoiceByIdParamsRequestSchema,
ReportIssueInvoiceByIdQueryRequestSchema,
} from "../../../../common/dto";
import { buildIssuedInvoicesDependencies } from "../../di";
import type { IssuedInvoicesInternalDeps } from "../../di";
import { GetIssuedInvoiceByIdController } from "./controllers";
import { ListIssuedInvoicesController } from "./controllers/list-issued-invoices.controller";
import { ReportIssuedInvoiceController } from "./controllers/report-issued-invoice.controller";
export const issuedInvoicesRouter = (params: ModuleParams) => {
export const issuedInvoicesRouter = (params: ModuleParams, deps: IssuedInvoicesInternalDeps) => {
const { app, baseRoutePath, logger } = params as {
app: Application;
database: Sequelize;
@ -24,8 +24,6 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
logger: ILogger;
};
const deps = buildIssuedInvoicesDependencies(params);
const router: Router = Router({ mergeParams: true });
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
// 🔐 Autenticación + Tenancy para TODO el router
@ -50,7 +48,7 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(ListIssuedInvoicesRequestSchema, "params"),
async (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.list_issued_invoices();
const useCase = deps.useCases.listIssuedInvoices();
const controller = new ListIssuedInvoicesController(useCase);
return controller.execute(req, res, next);
}
@ -61,7 +59,7 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
//checkTabContext,
validateRequest(GetIssueInvoiceByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.get_issued_invoice_by_id();
const useCase = deps.useCases.getIssuedInvoiceById();
const controller = new GetIssuedInvoiceByIdController(useCase);
return controller.execute(req, res, next);
}
@ -74,7 +72,7 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
validateRequest(ReportIssueInvoiceByIdQueryRequestSchema, "query"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.report_issued_invoice();
const useCase = deps.useCases.reportIssuedInvoice();
const controller = new ReportIssuedInvoiceController(useCase);
return controller.execute(req, res, next);
}

View File

@ -1,4 +1,4 @@
import type { IModuleServer, ModuleParams } from "@erp/core/api";
import type { IModuleServer } from "@erp/core/api";
import { models } from "./infrastructure";
@ -9,24 +9,67 @@ export const customersAPIModule: IModuleServer = {
version: "1.0.0",
dependencies: [],
async init(params: ModuleParams) {
// const contacts = getService<ContactsService>("contacts");
const { logger } = params;
//customersRouter(params);
logger.info("🚀 Customers module initialized", { label: this.name });
},
async registerDependencies(params) {
const { database, logger } = params;
/**
* Fase de SETUP
* ----------------
* - Construye el dominio (una sola vez)
* - Define qué expone el módulo
* - NO conecta infraestructura
*/
async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params;
logger.info("🚀 Customers module dependencies registered", {
label: this.name,
});
return {
// Modelos Sequelize del módulo
models,
// Servicios expuestos a otros módulos
services: {
/*...*/
customers: {
/*...*/
},
},
// Implementación privada del módulo
internal: {
customers: {
/*...*/
},
},
};
},
/**
* Fase de START
* -------------
* - Conecta el módulo al runtime
* - Puede usar servicios e internals ya construidos
* - NO construye dominio
*/
async start(params) {
const { app, baseRoutePath, logger, getInternal } = params;
// Recuperamos el dominio interno del módulo
const customersInternalDeps = getInternal("customers", "customers");
// Registro de rutas HTTP
//customersRouter(params, customersInternalDeps);
logger.info("🚀 Customers module started", {
label: this.name,
});
},
/**
* Warmup opcional (si lo necesitas en el futuro)
* ----------------------------------------------
* warmup(params) {
* ...
* }
*/
};
export default customersAPIModule;