From 2b3dfce72cc7895bbd2ad488b4d4cdcaf9a3f270 Mon Sep 17 00:00:00 2001 From: david Date: Sun, 8 Feb 2026 23:14:00 +0100 Subject: [PATCH] =?UTF-8?q?Nuevo=20sistema=20de=20carga=20de=20m=C3=B3dulo?= =?UTF-8?q?s=20+=20Generador=20de=20documetos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/lib/modules/module-loader.ts | 258 ++++++++++++------ .../src/lib/modules/service-registry.ts | 24 +- biome.json | 2 +- input.json | 1 + .../services/document-generation-service.ts | 9 +- .../services/document-storage.interface.ts | 4 +- .../src/api/application/renderers/index.ts | 1 - .../renderer-template-resolver.interface.ts | 4 - .../src/api/infrastructure/di/documents.di.ts | 9 +- .../fastreport-template-resolver.ts | 59 ++++ .../documents/renderers/fastreport/index.ts | 1 + .../api/modules/module-server.interface.ts | 33 ++- modules/core/src/api/modules/types.ts | 26 ++ .../report/issued-invoice-report-snapshot.ts | 2 + .../issued-invoices/di/use-cases.di.ts | 4 +- .../issued-invoices/services/index.ts | 1 - ...ssued-invoice-document-metadata-factory.ts | 2 +- .../issued-invoice-report-snapshot-builder.ts | 0 .../issued-invoice-report-snapshot-builder.ts | 2 + .../report-issued-invoice.use-case.ts | 98 +------ modules/customer-invoices/src/api/di.ts | 29 -- modules/customer-invoices/src/api/index.ts | 115 ++++---- .../src/api/infrastructure/di/documents.di.ts | 26 +- .../src/api/infrastructure/di/index.ts | 2 +- .../di/issued-invoices-services.ts | 26 ++ .../infrastructure/di/issued-invoices.di.ts | 101 ++----- ...ed-invoice-document-pipeline-factory.ts.ts | 5 +- .../issued-invoice-document-renderer.ts | 28 +- .../issued-invoices/issued-invoices.routes.ts | 12 +- modules/customers/src/api/index.ts | 63 ++++- 30 files changed, 548 insertions(+), 399 deletions(-) create mode 100644 input.json delete mode 100644 modules/core/src/api/application/renderers/renderer-template-resolver.interface.ts create mode 100644 modules/core/src/api/infrastructure/documents/renderers/fastreport/fastreport-template-resolver.ts delete mode 100644 modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-report-snapshot-builder.ts delete mode 100644 modules/customer-invoices/src/api/di.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/di/issued-invoices-services.ts diff --git a/apps/server/src/lib/modules/module-loader.ts b/apps/server/src/lib/modules/module-loader.ts index 84f821b7..996a0583 100644 --- a/apps/server/src/lib/modules/module-loader.ts +++ b/apps/server/src/lib/modules/module-loader.ts @@ -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 = new Map(); -const initializedModules = new Set(); -const visiting = new Set(); // 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(); // para detección de ciclos +const visiting = new Set(); +const setupOrder: string[] = []; + +// Internal store +const internalByModule: Map> = new Map(); + +// Tracking de dependencias realmente usadas por módulo +const usedDependenciesByModule: Map> = 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); + } 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 (serviceName: string): T => { + const [serviceModule] = serviceName.split(":"); + trackDependencyUse(moduleName, serviceModule); + + // IMPORTANTE: devolver el valor + return getServiceScoped(moduleName, pkg.dependencies ?? [], serviceName); + }; +} + +/** getInternal: por defecto solo permite al propio módulo acceder a su internal */ +function makeGetInternal(requesterModule: string) { + return (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(); + + // ❌ 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 }) - .warmup; + const maybeWarmup = (pkg as any).warmup as undefined | ((p: any) => Promise | 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( moduleName: string, - phase: - | "init" - | "registerDependencies" - | "registerModels" - | "registerServices" - | "initModels" - | "warmup", + phase: "setup" | "start" | "registerModels" | "registerServices" | "initModels" | "warmup", fn: () => Promise | T ): Promise { const startedAt = Date.now(); diff --git a/apps/server/src/lib/modules/service-registry.ts b/apps/server/src/lib/modules/service-registry.ts index 00948208..2a0c3f8d 100644 --- a/apps/server/src/lib/modules/service-registry.ts +++ b/apps/server/src/lib/modules/service-registry.ts @@ -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( + 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(name); +} + /** * Recupera un servicio registrado, con tipado opcional. */ -export function getService(name: string): T { +function getService(name: string): T { const service = services[name]; if (!service) { throw new Error(`❌ Servicio "${name}" no encontrado.`); diff --git a/biome.json b/biome.json index 42f8d8c8..3571e689 100644 --- a/biome.json +++ b/biome.json @@ -172,7 +172,7 @@ "useYield": "error" }, "complexity": { - "noBannedTypes": "error", + "noBannedTypes": "off", "noExcessiveCognitiveComplexity": { "level": "warn", "options": { diff --git a/input.json b/input.json new file mode 100644 index 00000000..1e632b2d --- /dev/null +++ b/input.json @@ -0,0 +1 @@ +{"id":"019c1ef1-7c3d-79ed-a12f-995cdc40252f","company_id":"5e4dc5b3-96b9-4968-9490-14bd032fec5f","invoice_number":"021","series":"F26","status":"issued","language_code":"es","currency_code":"EUR","invoice_date":"02/02/2026","payment_method":"DOMICILIACION BANCARIA","recipient":{"name":"AHUPA (Asociación Amigos Hospital Princesa)","tin":"G85096766","format_address":"C/ Diego de León, 45 3ºA\n28006 Madrid"},"items":[{"description":"Servicio mensual de mantenimiento de la web ahupa.es","quantity":"1","unit_amount":"64,00 €","subtotal_amount":"64,00 €","discount_percentage":"","discount_amount":"","taxable_amount":"64,00 €","taxes_amount":"13,44 €","total_amount":"77,44 €"},{"description":"Periodo: Febrero","quantity":"0","unit_amount":"","subtotal_amount":"","discount_percentage":"","discount_amount":"","taxable_amount":"","taxes_amount":"","total_amount":""},{"description":"","quantity":"","unit_amount":"","subtotal_amount":"","discount_percentage":"","discount_amount":"","taxable_amount":"","taxes_amount":"","total_amount":""}],"taxes":[{"taxable_amount":"64,00 €","iva_code":"iva_21","iva_percentage":"21,00 %","iva_amount":"13,44 €","rec_code":"","rec_percentage":"","rec_amount":"","retention_code":"","retention_percentage":"","retention_amount":"","taxes_amount":"13,44 €"}],"subtotal_amount":"64,00 €","discount_percentage":"","discount_amount":"","taxable_amount":"64,00 €","taxes_amount":"13,44 €","total_amount":"77,44 €","verifactu":{"id":"019c1ef1-7c49-7d1c-bf4d-56008cb14614","status":"Correcto","url":"https://www2.agenciatributaria.gob.es/wlpl/TIKE-CONT/ValidarQR?nif=B83999441&numserie=F26021&fecha=02-02-2026&importe=77.44","qr_code":"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","uuid":"bd21a72f-5c5b-4f7e-9b1f-bd9b92b96885","operacion":""}} \ No newline at end of file diff --git a/modules/core/src/api/application/documents/services/document-generation-service.ts b/modules/core/src/api/application/documents/services/document-generation-service.ts index abf5eebc..11aecb33 100644 --- a/modules/core/src/api/application/documents/services/document-generation-service.ts +++ b/modules/core/src/api/application/documents/services/document-generation-service.ts @@ -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; + export class DocumentGenerationService { constructor( private readonly metadataFactory: IDocumentMetadataFactory, @@ -29,7 +32,10 @@ export class DocumentGenerationService { private readonly sideEffects: readonly IDocumentSideEffect[] ) {} - async generate(snapshot: TSnapshot): Promise> { + async generate( + snapshot: TSnapshot, + params: DocumentGenerationServiceRenderParams + ): Promise> { let metadata: IDocumentMetadata; // 1. Metadata @@ -55,6 +61,7 @@ export class DocumentGenerationService { let document: IDocument; try { document = await this.renderer.render(snapshot, { + ...params, format: metadata.format, languageCode: metadata.languageCode, filename: metadata.filename, diff --git a/modules/core/src/api/application/documents/services/document-storage.interface.ts b/modules/core/src/api/application/documents/services/document-storage.interface.ts index d36d9d07..5469f6be 100644 --- a/modules/core/src/api/application/documents/services/document-storage.interface.ts +++ b/modules/core/src/api/application/documents/services/document-storage.interface.ts @@ -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; + save(document: IDocument, metadata: Record): Promise; } diff --git a/modules/core/src/api/application/renderers/index.ts b/modules/core/src/api/application/renderers/index.ts index 13911d6d..f867eff9 100644 --- a/modules/core/src/api/application/renderers/index.ts +++ b/modules/core/src/api/application/renderers/index.ts @@ -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"; diff --git a/modules/core/src/api/application/renderers/renderer-template-resolver.interface.ts b/modules/core/src/api/application/renderers/renderer-template-resolver.interface.ts deleted file mode 100644 index 3878b0d5..00000000 --- a/modules/core/src/api/application/renderers/renderer-template-resolver.interface.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IRendererTemplateResolver { - /** Devuelve la ruta absoluta del fichero de plantilla */ - resolveTemplatePath(module: string, companySlug: string, templateName: string): string; -} diff --git a/modules/core/src/api/infrastructure/di/documents.di.ts b/modules/core/src/api/infrastructure/di/documents.di.ts index dc839211..fe2aec24 100644 --- a/modules/core/src/api/infrastructure/di/documents.di.ts +++ b/modules/core/src/api/infrastructure/di/documents.di.ts @@ -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, }, }; -} +}; diff --git a/modules/core/src/api/infrastructure/documents/renderers/fastreport/fastreport-template-resolver.ts b/modules/core/src/api/infrastructure/documents/renderers/fastreport/fastreport-template-resolver.ts new file mode 100644 index 00000000..b8c663ad --- /dev/null +++ b/modules/core/src/api/infrastructure/documents/renderers/fastreport/fastreport-template-resolver.ts @@ -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) { + // //templates// + return this.resolveJoin([module, "templates", companySlug]); + } + + // /templates/// + //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"); + } +} diff --git a/modules/core/src/api/infrastructure/documents/renderers/fastreport/index.ts b/modules/core/src/api/infrastructure/documents/renderers/fastreport/index.ts index 266272fe..87a63ee7 100644 --- a/modules/core/src/api/infrastructure/documents/renderers/fastreport/index.ts +++ b/modules/core/src/api/infrastructure/documents/renderers/fastreport/index.ts @@ -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"; diff --git a/modules/core/src/api/modules/module-server.interface.ts b/modules/core/src/api/modules/module-server.interface.ts index 25fa867e..deee2f26 100644 --- a/modules/core/src/api/modules/module-server.interface.ts +++ b/modules/core/src/api/modules/module-server.interface.ts @@ -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; - registerDependencies?(params: ModuleParams): Promise; + 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; + + /** + * Fase de arranque del módulo. + * Aquí se conecta el módulo al runtime. + */ + start?: (params: StartParams) => Promise | void; + + /** + * Warmup opcional (IO, precargas, validaciones externas). + */ + warmup?: (params: WarmupParams) => Promise | void; } diff --git a/modules/core/src/api/modules/types.ts b/modules/core/src/api/modules/types.ts index c8d3d7a0..0055d68f 100644 --- a/modules/core/src/api/modules/types.ts +++ b/modules/core/src/api/modules/types.ts @@ -15,3 +15,29 @@ export interface ModuleDependencies { models?: any[]; services?: { [key: string]: any }; } + +export type ModuleSetupResult = { + models?: any[]; + services?: Record; + internal?: Record; +}; + +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: (serviceName: string) => T; + + /** + * Acceso a internal del propio módulo. + * No debe usarse para consumir otros módulos. + */ + getInternal: (moduleName: string, key?: string) => T; +}; + +export type WarmupParams = StartParams; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/application-models/snapshots/report/issued-invoice-report-snapshot.ts b/modules/customer-invoices/src/api/application/issued-invoices/application-models/snapshots/report/issued-invoice-report-snapshot.ts index 4cead399..0f8104d8 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/application-models/snapshots/report/issued-invoice-report-snapshot.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/application-models/snapshots/report/issued-invoice-report-snapshot.ts @@ -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; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/di/use-cases.di.ts b/modules/customer-invoices/src/api/application/issued-invoices/di/use-cases.di.ts index e474c530..828730aa 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/di/use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/di/use-cases.di.ts @@ -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( diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts index e97584f3..15040b53 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/index.ts @@ -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"; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts index eefe2530..39134cb9 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-document-metadata-factory.ts @@ -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) { diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-report-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/issued-invoice-report-snapshot-builder.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts index e6c5f473..c29ba7d2 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/snapshot-builders/report/issued-invoice-report-snapshot-builder.ts @@ -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, diff --git a/modules/customer-invoices/src/api/application/issued-invoices/use-cases/report-issued-invoices/report-issued-invoice.use-case.ts b/modules/customer-invoices/src/api/application/issued-invoices/use-cases/report-issued-invoices/report-issued-invoice.use-case.ts index 7a3a7515..13533295 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/use-cases/report-issued-invoices/report-issued-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/use-cases/report-issued-invoices/report-issued-invoice.use-case.ts @@ -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({ - resource: "issued-invoice", - projection: "FULL", - }); - - const reportPresenter = this.presenterRegistry.getPresenter({ - resource: "issued-invoice", - projection: "REPORT", - }); - - const renderer = this.rendererRegistry.getRenderer({ - 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, - filename: `customer-invoice-${invoice.invoiceNumber}.pdf`, - }); - } catch (error: unknown) { - return Result.fail(error as Error); - } - }); } } diff --git a/modules/customer-invoices/src/api/di.ts b/modules/customer-invoices/src/api/di.ts deleted file mode 100644 index c126b968..00000000 --- a/modules/customer-invoices/src/api/di.ts +++ /dev/null @@ -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; diff --git a/modules/customer-invoices/src/api/index.ts b/modules/customer-invoices/src/api/index.ts index 63e7ee29..eb766651 100644 --- a/modules/customer-invoices/src/api/index.ts +++ b/modules/customer-invoices/src/api/index.ts @@ -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( + "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; diff --git a/modules/customer-invoices/src/api/infrastructure/di/documents.di.ts b/modules/customer-invoices/src/api/infrastructure/di/documents.di.ts index 3beb4300..498ac7ef 100644 --- a/modules/customer-invoices/src/api/infrastructure/di/documents.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/di/documents.di.ts @@ -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); diff --git a/modules/customer-invoices/src/api/infrastructure/di/index.ts b/modules/customer-invoices/src/api/infrastructure/di/index.ts index 5d10b1e3..99c2c9c5 100644 --- a/modules/customer-invoices/src/api/infrastructure/di/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/di/index.ts @@ -1,2 +1,2 @@ export * from "./issued-invoices.di"; -//export * from "./proformas.di"; +export * from "./issued-invoices-services"; diff --git a/modules/customer-invoices/src/api/infrastructure/di/issued-invoices-services.ts b/modules/customer-invoices/src/api/infrastructure/di/issued-invoices-services.ts new file mode 100644 index 00000000..945db7d7 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/di/issued-invoices-services.ts @@ -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), + }, + }; +} diff --git a/modules/customer-invoices/src/api/infrastructure/di/issued-invoices.di.ts b/modules/customer-invoices/src/api/infrastructure/di/issued-invoices.di.ts index 5ae8f275..2721d604 100644 --- a/modules/customer-invoices/src/api/infrastructure/di/issued-invoices.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/di/issued-invoices.di.ts @@ -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, }), }, diff --git a/modules/customer-invoices/src/api/infrastructure/documents/pipelines/issued-invoice-document-pipeline-factory.ts.ts b/modules/customer-invoices/src/api/infrastructure/documents/pipelines/issued-invoice-document-pipeline-factory.ts.ts index d3355975..28c892b7 100644 --- a/modules/customer-invoices/src/api/infrastructure/documents/pipelines/issued-invoice-document-pipeline-factory.ts.ts +++ b/modules/customer-invoices/src/api/infrastructure/documents/pipelines/issued-invoice-document-pipeline-factory.ts.ts @@ -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) diff --git a/modules/customer-invoices/src/api/infrastructure/documents/renderers/fastreport/issued-invoice-document-renderer.ts b/modules/customer-invoices/src/api/infrastructure/documents/renderers/fastreport/issued-invoice-document-renderer.ts index 22ef472f..d678e3c1 100644 --- a/modules/customer-invoices/src/api/infrastructure/documents/renderers/fastreport/issued-invoice-document-renderer.ts +++ b/modules/customer-invoices/src/api/infrastructure/documents/renderers/fastreport/issued-invoice-document-renderer.ts @@ -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 { constructor( private readonly fastReportRenderer: FastReportRenderer, - private readonly templatePath: string + private readonly templateResolver: FastReportTemplateResolver ) {} - async render(snapshot: IssuedInvoiceReportSnapshot): Promise { + async render( + snapshot: IssuedInvoiceReportSnapshot, + params: IssuedInvoiceDocumentRenderParams + ): Promise { + 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", }); diff --git a/modules/customer-invoices/src/api/infrastructure/express/issued-invoices/issued-invoices.routes.ts b/modules/customer-invoices/src/api/infrastructure/express/issued-invoices/issued-invoices.routes.ts index 1edc608d..02b7289b 100644 --- a/modules/customer-invoices/src/api/infrastructure/express/issued-invoices/issued-invoices.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/express/issued-invoices/issued-invoices.routes.ts @@ -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); } diff --git a/modules/customers/src/api/index.ts b/modules/customers/src/api/index.ts index 7f865797..2c4892f2 100644 --- a/modules/customers/src/api/index.ts +++ b/modules/customers/src/api/index.ts @@ -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("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;