diff --git a/modules/core/src/api/application/index.ts b/modules/core/src/api/application/index.ts index 49bbc161..aee6f87b 100644 --- a/modules/core/src/api/application/index.ts +++ b/modules/core/src/api/application/index.ts @@ -1 +1,2 @@ export * from "./errors"; +export * from "./presenters"; diff --git a/modules/core/src/api/application/presenters/index.ts b/modules/core/src/api/application/presenters/index.ts new file mode 100644 index 00000000..83e6546d --- /dev/null +++ b/modules/core/src/api/application/presenters/index.ts @@ -0,0 +1,2 @@ +export * from "./presenter-registry"; +export * from "./presenter-registry.interface"; diff --git a/modules/core/src/api/application/presenters/presenter-registry.interface.ts b/modules/core/src/api/application/presenters/presenter-registry.interface.ts new file mode 100644 index 00000000..57d27263 --- /dev/null +++ b/modules/core/src/api/application/presenters/presenter-registry.interface.ts @@ -0,0 +1,57 @@ +import { IPresenter } from "./presenter.interface"; + +/** + * 🔑 Claves de proyección comunes para seleccionar presenters + */ +export type PresenterKey = { + resource: string; // "customer-invoice" + projection: string; //"detail" | "summary" | "created" | "status" | "export"; + format: string; //"json" | "pdf" | "csv" | "xml"; + version?: number; // 1 | 2 + locale?: string; // es | en | fr +}; + +/** + * Ejemplo de uso: + * + * const registry = new InMemoryPresenterRegistry(); + * + * // Registro + * registry.register( + * { resource: "customer-invoice", projection: "detail", format: "json", version: 1 }, + * new CustomerInvoiceDetailPresenter() + * ); + * + * registry.register( + * { resource: "customer-invoice", projection: "detail", format: "pdf", version: 1 }, + * new CustomerInvoicePdfPresenter() + * ); + * + * // Resolución + * const presenterOrNone = registry.resolve({ + * resource: "customer-invoice", + * projection: "detail", + * format: "pdf", + * }); + * + * presenterOrNone.map(async (presenter) => { + * const output = await (presenter as IAsyncPresenter).toOutput(invoice); + * console.log("PDF generado:", output); + * }); + * + **/ + +export interface IPresenterRegistry { + /** + * Obtiene un mapper de dominio por clave de proyección. + */ + getPresenter(key: PresenterKey): IPresenter; + + /** + * Registra un mapper de dominio bajo una clave de proyección. + */ + registerPresenter( + key: PresenterKey, + presenter: IPresenter + ): void; +} diff --git a/modules/core/src/api/application/presenters/presenter-registry.ts b/modules/core/src/api/application/presenters/presenter-registry.ts new file mode 100644 index 00000000..155e2f6a --- /dev/null +++ b/modules/core/src/api/application/presenters/presenter-registry.ts @@ -0,0 +1,64 @@ +import { ApplicationError } from "../errors"; +import { IPresenterRegistry, PresenterKey } from "./presenter-registry.interface"; +import { IPresenter } from "./presenter.interface"; + +export class InMemoryPresenterRegistry implements IPresenterRegistry { + private registry: Map> = new Map(); + + getPresenter(key: PresenterKey): IPresenter { + const exactKey = this._buildKey(key); + + // 1) Intentar clave exacta + if (this.registry.has(exactKey)) { + return this.registry.get(exactKey)!; + } + + // 2) Fallback por versión: si no se indicó, buscar la última registrada + if (key.version === undefined) { + const candidates = [...this.registry.keys()].filter((k) => + k.startsWith(this._buildKey({ ...key, version: undefined, locale: undefined })) + ); + + if (candidates.length > 0) { + const latest = candidates.sort().pop()!; // simplificación: versión más alta lexicográficamente + return this.registry.get(latest)!; + } + } + + // 3) Fallback por locale: intentar sin locale si no se encuentra exacto + if (key.locale) { + const withoutLocale = this._buildKey({ ...key, locale: undefined }); + if (this.registry.has(withoutLocale)) { + return this.registry.get(withoutLocale)!; + } + } + + if (!this.registry.has(exactKey)) { + throw new ApplicationError(`Error. Presenter ${key} not registred!`); + } + + throw new ApplicationError(`Error. Presenter ${key} not registred!`); + } + + registerPresenter( + key: PresenterKey, + presenter: IPresenter + ): void { + const exactKey = this._buildKey(key); + this.registry.set(exactKey, presenter); + } + + /** + * 🔹 Construye la clave única para el registro. + */ + private _buildKey(key: PresenterKey): string { + const { resource, projection, format, version, locale } = key; + return [ + resource.toLowerCase(), + projection.toLowerCase(), + format.toLowerCase(), + version ?? "latest", + locale ?? "default", + ].join("::"); + } +} diff --git a/modules/core/src/api/application/presenters/presenter.interface.ts b/modules/core/src/api/application/presenters/presenter.interface.ts new file mode 100644 index 00000000..3703cbdd --- /dev/null +++ b/modules/core/src/api/application/presenters/presenter.interface.ts @@ -0,0 +1,32 @@ +export type DTO = T; +export type BinaryOutput = Buffer; // Puedes ampliar a Readable si usas streams + +interface ISyncPresenter { + toOutput(source: TSource): TOutput; +} + +interface IAsyncPresenter { + toOutput(source: TSource): Promise; +} + +/** + * Proyección SINCRÓNICA de colecciones. + * Útil para listados paginados, exportaciones ligeras, etc. + */ +/*export interface ISyncBulkPresenter { + toOutput(source: TSource): TOutput; +}*/ + +/** + * Proyección ASÍNCRONA de colecciones. + * Útil para generar varios PDFs/CSVs. + */ +/*export interface IAsyncBulkPresenter { + toOutput(source: TSource): Promise; +}*/ + +export type IPresenter = + | ISyncPresenter + | IAsyncPresenter; +//| ISyncBulkPresenter +//| IAsyncBulkPresenter; diff --git a/modules/customer-invoices/src/api/application/index.ts b/modules/customer-invoices/src/api/application/index.ts index 14646c6b..97ba1ed7 100644 --- a/modules/customer-invoices/src/api/application/index.ts +++ b/modules/customer-invoices/src/api/application/index.ts @@ -2,4 +2,5 @@ export * from "./create-customer-invoice"; export * from "./delete-customer-invoice"; export * from "./get-customer-invoice"; export * from "./list-customer-invoices"; -export * from "./update-customer-invoice"; +export * from "./report-customer-invoice"; +//export * from "./update-customer-invoice"; diff --git a/modules/customer-invoices/src/api/application/report-customer-invoice/index.ts b/modules/customer-invoices/src/api/application/report-customer-invoice/index.ts new file mode 100644 index 00000000..ebba15ce --- /dev/null +++ b/modules/customer-invoices/src/api/application/report-customer-invoice/index.ts @@ -0,0 +1 @@ +export * from "./report-customer-invoice.use-case"; diff --git a/modules/customer-invoices/src/api/application/report-customer-invoice/report-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/report-customer-invoice/report-customer-invoice.use-case.ts new file mode 100644 index 00000000..9c1b0437 --- /dev/null +++ b/modules/customer-invoices/src/api/application/report-customer-invoice/report-customer-invoice.use-case.ts @@ -0,0 +1,50 @@ +import { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; +import { CustomerInvoiceService } from "../../domain"; +import { ReportCustomerInvoiceAssembler } from "./assembler"; + +type ReportCustomerInvoiceUseCaseInput = { + companyId: UniqueID; + invoice_id: string; +}; + +export class ReportCustomerInvoiceUseCase { + constructor( + private readonly service: CustomerInvoiceService, + private readonly transactionManager: ITransactionManager, + private readonly assembler: ReportCustomerInvoiceAssembler + ) {} + + public execute(params: ReportCustomerInvoiceUseCaseInput) { + const { invoice_id, companyId } = params; + + const idOrError = UniqueID.create(invoice_id); + + if (idOrError.isFailure) { + return Result.fail(idOrError.error); + } + + const invoiceId = idOrError.data; + + return this.transactionManager.complete(async (transaction) => { + try { + const invoiceOrError = await this.service.getInvoiceByIdInCompany( + companyId, + invoiceId, + transaction + ); + if (invoiceOrError.isFailure) { + return Result.fail(invoiceOrError.error); + } + + const invoiceDto = this.registry.getPresenter("").toDTO(invoideOIrError.data); + + const pdfData = this.assembler.toPDF(invoiceDto); + return Result.ok(pdfData); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/customer-invoice.reporter.ts b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/customer-invoice.reporter.ts new file mode 100644 index 00000000..f052e72c --- /dev/null +++ b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/customer-invoice.reporter.ts @@ -0,0 +1,97 @@ +import { toEmptyString } from "@repo/rdx-ddd"; +import * as handlebars from "handlebars"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import puppeteer from "puppeteer"; +import report from "puppeteer-report"; +import { CustomerInvoice } from "../../../domain"; + +export interface ICustomerInvoiceReporter { + toHTML: (invoice: CustomerInvoice) => Promise; + toPDF: (invoice: CustomerInvoice) => Promise; +} + +// https://plnkr.co/edit/lWk6Yd?preview + +export const CustomerInvoiceReporter: ICustomerInvoiceReporter = { + toHTML: async (invoice: CustomerInvoice): Promise => { + const quote_dto = await map(quote, context); + + // Obtener y compilar la plantilla HTML + const templateHtml = readFileSync( + path.join(__dirname, "./templates/quote/template.hbs") + ).toString(); + const template = handlebars.compile(templateHtml, {}); + return template(quote_dto); + }, + + toPDF: async (quote: CustomerInvoice, context: ISalesContext): Promise => { + const html = await CustomerInvoiceReporter.toHTML(quote, context); + + // Generar el PDF con Puppeteer + const browser = await puppeteer.launch({ + args: [ + "--disable-extensions", + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + ], + }); + + const page = await browser.newPage(); + const navigationPromise = page.waitForNavigation(); + await page.setContent(html, { waitUntil: "networkidle2" }); + + await navigationPromise; + const reportPDF = await report.pdfPage(page, { + format: "A4", + margin: { + bottom: "10mm", + left: "10mm", + right: "10mm", + top: "10mm", + }, + }); + + await browser.close(); + return Buffer.from(reportPDF); + }, +}; + +const map = async (invoice: CustomerInvoice) => { + return { + id: invoice.id.toString(), + company_id: invoice.companyId.toString(), + + invoice_number: invoice.invoiceNumber.toString(), + status: invoice.status.toPrimitive(), + series: toEmptyString(invoice.series, (value) => value.toString()), + + invoice_date: invoice.invoiceDate.toDateString(), + operation_date: toEmptyString(invoice.operationDate, (value) => value.toDateString()), + + notes: toEmptyString(invoice.notes, (value) => value.toString()), + + language_code: invoice.languageCode.toString(), + currency_code: invoice.currencyCode.toString(), + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const quoteItemPresenter = ( + items: ICollection, + context: ISalesContext +): any[] => + items.totalCount > 0 + ? items.items.map((item: CustomerInvoiceItem) => ({ + id_article: item.idArticle.toString(), + description: item.description.toString(), + quantity: item.quantity.toFormat(), + unit_price: item.unitPrice.toFormat(), + subtotal_price: item.subtotalPrice.toFormat(), + discount: item.discount.toFormat(), + total_price: item.totalPrice.toFormat(), + })) + : []; +2; diff --git a/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/index.ts b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/index.ts new file mode 100644 index 00000000..8f6ec562 --- /dev/null +++ b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/index.ts @@ -0,0 +1 @@ +export * from "./customer-invoice.reporter"; diff --git a/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/template.hbs b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/template.hbs new file mode 100644 index 00000000..a14b7fe9 --- /dev/null +++ b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/template.hbs @@ -0,0 +1,152 @@ + + + + + + Presupuesto #{{id}} + + + + + + +
+
+ + + + + + + + + + + + + {{#each items}} + + + + + + + + + {{/each}} + +
Cant.DescripciónPrec. UnitarioSubtotalDto (%)Importe total
{{quantity}}{{description}}{{unit_price}}{{subtotal_price}}{{discount}}{{total_price}}
+
+ +
+ +
+
+

Forma de pago: {{payment_method}}

+
+
+

Notas: {{notes}}

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Importe neto{{subtotal_price}}
% Descuento{{discount.amount}}{{discount_price}}
Base imponible{{before_tax_price}}
% IVA{{tax}}{{tax_price}}
Importe total{{total_price}}
+
+
+ +
+
+ +
+ + + + \ No newline at end of file diff --git a/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/uecko-footer-logos.jpg b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/uecko-footer-logos.jpg new file mode 100644 index 00000000..dbaaf5ea Binary files /dev/null and b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/uecko-footer-logos.jpg differ diff --git a/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/uecko-logo.svg b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/uecko-logo.svg new file mode 100644 index 00000000..c5624526 --- /dev/null +++ b/modules/customer-invoices/src/api/application/report-customer-invoice/reporter/templates/quote/uecko-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/modules/customer-invoices/src/api/infrastructure/dependencies.ts b/modules/customer-invoices/src/api/infrastructure/dependencies.ts index 64922ed6..804249db 100644 --- a/modules/customer-invoices/src/api/infrastructure/dependencies.ts +++ b/modules/customer-invoices/src/api/infrastructure/dependencies.ts @@ -1,6 +1,10 @@ import { JsonTaxCatalogProvider, spainTaxCatalogProvider } from "@erp/core"; -import type { IMapperRegistry, ModuleParams } from "@erp/core/api"; -import { InMemoryMapperRegistry, SequelizeTransactionManager } from "@erp/core/api"; +import type { IMapperRegistry, IPresenterRegistry, ModuleParams } from "@erp/core/api"; +import { + InMemoryMapperRegistry, + InMemoryPresenterRegistry, + SequelizeTransactionManager, +} from "@erp/core/api"; import { CreateCustomerInvoiceAssembler, CreateCustomerInvoiceUseCase, @@ -9,6 +13,7 @@ import { GetCustomerInvoiceUseCase, ListCustomerInvoicesAssembler, ListCustomerInvoicesUseCase, + ReportCustomerInvoiceUseCase, UpdateCustomerInvoiceAssembler, UpdateCustomerInvoiceUseCase, } from "../application"; @@ -24,7 +29,7 @@ type InvoiceDeps = { catalogs: { taxes: JsonTaxCatalogProvider; }; - assemblers: { + presenters: { list: ListCustomerInvoicesAssembler; get: GetCustomerInvoiceAssembler; create: CreateCustomerInvoiceAssembler; @@ -36,16 +41,16 @@ type InvoiceDeps = { create: () => CreateCustomerInvoiceUseCase; update: () => UpdateCustomerInvoiceUseCase; delete: () => DeleteCustomerInvoiceUseCase; - }; - presenters: { - // list: (res: Response) => ListPresenter; + report: () => ReportCustomerInvoiceUseCase; }; }; -let _repo: CustomerInvoiceRepository | null = null; +let _presenterRegistry: IPresenterRegistry | null = null; let _mapperRegistry: IMapperRegistry | null = null; + +let _repo: CustomerInvoiceRepository | null = null; let _service: CustomerInvoiceService | null = null; -let _assemblers: InvoiceDeps["assemblers"] | null = null; +const _presenters: InvoiceDeps["presenters"] | null = null; let _catalogs: InvoiceDeps["catalogs"] | null = null; export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps { @@ -53,26 +58,28 @@ export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps { const transactionManager = new SequelizeTransactionManager(database); if (!_catalogs) _catalogs = { taxes: spainTaxCatalogProvider }; - const fullMapper: CustomerInvoiceFullMapper = new CustomerInvoiceFullMapper({ - taxCatalog: _catalogs!.taxes, - }); - const listMapper = new CustomerInvoiceListMapper(); - if (!_mapperRegistry) { _mapperRegistry = new InMemoryMapperRegistry(); - _mapperRegistry.registerDomainMapper("FULL", fullMapper); - _mapperRegistry.registerReadModelMapper("LIST", listMapper); + _mapperRegistry.registerDomainMapper( + "FULL", + new CustomerInvoiceFullMapper({ + taxCatalog: _catalogs!.taxes, + }) + ); + _mapperRegistry.registerReadModelMapper("LIST", new CustomerInvoiceListMapper()); } if (!_repo) _repo = new CustomerInvoiceRepository({ mapperRegistry: _mapperRegistry, database }); if (!_service) _service = new CustomerInvoiceService(_repo); - if (!_assemblers) { - _assemblers = { + if (!_presenterRegistry) { + _presenterRegistry = new InMemoryPresenterRegistry(); + _presenterRegistry.registerPresenter(key, mapper); + /*_presenters = { list: new ListCustomerInvoicesAssembler(), // transforma domain → ListDTO get: new GetCustomerInvoiceAssembler(), // transforma domain → DetailDTO create: new CreateCustomerInvoiceAssembler(), // transforma domain → CreatedDTO update: new UpdateCustomerInvoiceAssembler(), // transforma domain -> UpdateDTO - }; + };*/ } return { @@ -80,31 +87,29 @@ export function getInvoiceDependencies(params: ModuleParams): InvoiceDeps { repo: _repo, mapperRegistry: _mapperRegistry, service: _service, - assemblers: _assemblers, + presenters: _presenters, catalogs: _catalogs, build: { list: () => - new ListCustomerInvoicesUseCase(_service!, transactionManager!, _assemblers!.list), - get: () => new GetCustomerInvoiceUseCase(_service!, transactionManager!, _assemblers!.get), + new ListCustomerInvoicesUseCase(_service!, transactionManager!, _presenters!.list), + get: () => new GetCustomerInvoiceUseCase(_service!, transactionManager!, _presenters!.get), create: () => new CreateCustomerInvoiceUseCase( _service!, transactionManager!, - _assemblers!.create, + _presenters!.create, _catalogs!.taxes ), update: () => new UpdateCustomerInvoiceUseCase( _service!, transactionManager!, - _assemblers!.update, + _presenters!.update, _catalogs!.taxes ), delete: () => new DeleteCustomerInvoiceUseCase(_service!, transactionManager!), - }, - presenters: { - //list: (res: Response) => createListPresenter(res), - //json: (res: Response, status: number = 200) => createJsonPresenter(res, status), + report: () => + new ReportCustomerInvoiceUseCase(_service!, transactionManager!, _presenters!.get), }, }; } diff --git a/modules/customer-invoices/src/api/infrastructure/express/controllers/report-customer-invoice.controller.ts b/modules/customer-invoices/src/api/infrastructure/express/controllers/report-customer-invoice.controller.ts new file mode 100644 index 00000000..5b1984f3 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/express/controllers/report-customer-invoice.controller.ts @@ -0,0 +1,25 @@ +import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api"; +import { GetCustomerInvoiceUseCase, GetCustomerInvoiceUseCase as ReportCustomerInvoiceUseCase } from "../../../application"; + +export class ReportCustomerInvoiceController extends ExpressController { + public constructor(private readonly useCase: ReportCustomerInvoiceUseCase) { + super(); + // 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query + this.useGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId")); + } + + protected async executeImpl() { + const companyId = this.getTenantId()!; // garantizado por tenantGuard + const { invoice_id } = this.req.params; + + const getUseCase = getUsecaasdasd; + const invoiceDto = await + + const result = await this.useCase.execute({ invoice_id, companyId, }); + + return result.match( + (data) => this.downloadPDF(result.data), + (err) => this.handleError(err) + ); + } +} diff --git a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts index afe13678..708ae8e5 100644 --- a/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/express/customer-invoices.routes.ts @@ -7,6 +7,7 @@ import { CustomerInvoiceListRequestSchema, DeleteCustomerInvoiceByIdRequestSchema, GetCustomerInvoiceByIdRequestSchema, + ReportCustomerInvoiceByIdRequestSchema, } from "../../../common/dto"; import { getInvoiceDependencies } from "../dependencies"; import { @@ -104,5 +105,16 @@ export const customerInvoicesRouter = (params: ModuleParams) => { } ); + router.get( + "/:invoice_id/report", + //checkTabContext, + validateRequest(ReportCustomerInvoiceByIdRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.build.report(); + const controller = new ReportCustomerInvoiceController(useCase); + return controller.execute(req, res, next); + } + ); + app.use(`${baseRoutePath}/customer-invoices`, router); }; diff --git a/packages/rdx-utils/src/helpers/id-utils.ts b/packages/rdx-utils/src/helpers/id-utils.ts index 1204494b..efe232a2 100644 --- a/packages/rdx-utils/src/helpers/id-utils.ts +++ b/packages/rdx-utils/src/helpers/id-utils.ts @@ -1,3 +1,4 @@ -import { v4 as uuidv4 } from "uuid"; +import { v4 as uuidv4, v7 as uuidv7 } from "uuid"; export const generateUUIDv4 = (): string => uuidv4(); +export const generateUUIDv7 = (): string => uuidv7();