Proformas preview

This commit is contained in:
David Arranz 2026-07-07 15:29:30 +02:00
parent cc14cdacf1
commit 588e9dd733
61 changed files with 1004 additions and 719 deletions

View File

@ -39,6 +39,7 @@
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@erp/companies": "workspace:*",
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/catalogs": "workspace:*", "@erp/catalogs": "workspace:*",
"@erp/customers": "workspace:*", "@erp/customers": "workspace:*",

View File

@ -4,6 +4,7 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { import type {
ICompanyReportProfileFinder,
IProformaCreator, IProformaCreator,
IProformaFinder, IProformaFinder,
IProformaFullReadModelAssembler, IProformaFullReadModelAssembler,
@ -24,7 +25,8 @@ import {
GetProformaByIdUseCase, GetProformaByIdUseCase,
IssueProformaUseCase, IssueProformaUseCase,
ListProformasUseCase, ListProformasUseCase,
ReportProformaUseCase, PreviewProformaReportUseCase,
ReportProformaPdfUseCase,
UpdateProformaByIdUseCase, UpdateProformaByIdUseCase,
} from "../use-cases"; } from "../use-cases";
@ -54,16 +56,38 @@ export function buildListProformasUseCase(deps: {
); );
} }
export function buildReportProformaUseCase(deps: { export function buildReportProformaPdfUseCase(deps: {
finder: IProformaFinder; finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler; fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder; fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder; reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService; documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
return new ReportProformaUseCase({ return new ReportProformaPdfUseCase({
finder: deps.finder, finder: deps.finder,
companyReportProfileFinder: deps.companyReportProfileFinder,
fullReadModelAssembler: deps.fullReadModelAssembler,
fullSnapshotBuilder: deps.fullSnapshotBuilder,
reportSnapshotBuilder: deps.reportSnapshotBuilder,
documentService: deps.documentService,
transactionManager: deps.transactionManager,
});
}
export function buildPreviewProformaReportUseCase(deps: {
finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager;
}) {
return new PreviewProformaReportUseCase({
finder: deps.finder,
companyReportProfileFinder: deps.companyReportProfileFinder,
fullReadModelAssembler: deps.fullReadModelAssembler, fullReadModelAssembler: deps.fullReadModelAssembler,
fullSnapshotBuilder: deps.fullSnapshotBuilder, fullSnapshotBuilder: deps.fullSnapshotBuilder,
reportSnapshotBuilder: deps.reportSnapshotBuilder, reportSnapshotBuilder: deps.reportSnapshotBuilder,

View File

@ -0,0 +1,15 @@
export class CompanyReportProfileNotFoundError extends Error {
public readonly code = "COMPANY_REPORT_PROFILE_NOT_FOUND";
public constructor(companyId: string) {
super(
`Cannot generate the proforma report because the company document profile is missing for company ${companyId}.`
);
this.name = "CompanyReportProfileNotFoundError";
}
}
export const isCompanyReportProfileNotFoundError = (
error: unknown
): error is CompanyReportProfileNotFoundError =>
error instanceof CompanyReportProfileNotFoundError;

View File

@ -0,0 +1 @@
export * from "./company-report-profile-not-found.error";

View File

@ -1,4 +1,5 @@
export * from "./di"; export * from "./di";
export * from "./errors";
export * from "./mappers"; export * from "./mappers";
export * from "./models"; export * from "./models";
export * from "./repositories"; export * from "./repositories";

View File

@ -0,0 +1,30 @@
import type { Maybe } from "@repo/rdx-utils";
export type CompanyReportLogo = {
url: string;
};
export type CompanyReportAddress = {
street: string;
street2: Maybe<string>;
postalCode: Maybe<string>;
city: Maybe<string>;
province: Maybe<string>;
country: Maybe<string>;
};
export type CompanyReportProfile = {
companyId: string;
slug: string;
legalName: string;
tradeName: Maybe<string>;
tin: Maybe<string>;
email: Maybe<string>;
phone: Maybe<string>;
website: Maybe<string>;
locale: Maybe<string>;
currencyCode: Maybe<string>;
logo: Maybe<CompanyReportLogo>;
address: Maybe<CompanyReportAddress>;
version: Maybe<string>;
};

View File

@ -1,5 +1,7 @@
export * from "./company-report-profile.model";
export * from "./proforma-create-input.model"; export * from "./proforma-create-input.model";
export * from "./proforma-full-read.model"; export * from "./proforma-full-read.model";
export * from "./proforma-item-tax-codes-input.model"; export * from "./proforma-item-tax-codes-input.model";
export * from "./proforma-summary"; export * from "./proforma-summary";
export * from "./proforma-update-input.model"; export * from "./proforma-update-input.model";
export * from "./report-localization.model";

View File

@ -0,0 +1,5 @@
export type ReportLocalization = {
languageCode: string;
locale: string;
currencyCode: string;
};

View File

@ -0,0 +1,11 @@
import type { UniqueID } from "@repo/rdx-ddd";
import type { Maybe, Result } from "@repo/rdx-utils";
import type { CompanyReportProfile } from "../models";
export interface ICompanyReportProfileFinder {
findByCompanyId(
companyId: UniqueID,
transaction?: unknown
): Promise<Result<Maybe<CompanyReportProfile>, Error>>;
}

View File

@ -1,8 +1,8 @@
export * from "./assemblers"; export * from "./assemblers";
export * from "./catalog-resolver"; export * from "./catalog-resolver";
export * from "./company-report-profile-finder";
export * from "./proforma-creator"; export * from "./proforma-creator";
export * from "./proforma-document-generator.interface"; export * from "./proforma-document-generator.interface";
export * from "./proforma-document-metadata-factory";
export * from "./proforma-document-properties-factory"; export * from "./proforma-document-properties-factory";
export * from "./proforma-finder"; export * from "./proforma-finder";
export * from "./proforma-issuer"; export * from "./proforma-issuer";

View File

@ -1,47 +0,0 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../snapshot-builders";
/**
* Construye los metadatos del documento PDF de una factura emitida.
*
* - Application-level
* - Determinista
* - Sin IO
*/
export class ProformaDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot>
{
build(snapshot: ProformaReportSnapshot): IDocumentMetadata {
if (!snapshot.id) {
throw new Error("ProformaReportSnapshot.id is required");
}
if (!snapshot.company_id) {
throw new Error("ProformaReportSnapshot.companyId is required");
}
return {
documentType: "proforma",
documentId: snapshot.id,
companyId: snapshot.company_id,
companySlug: snapshot.company_slug,
format: "PDF",
languageCode: snapshot.language_code ?? "es",
filename: this.buildFilename(snapshot),
storageKey: this.buildCacheKey(snapshot),
};
}
private buildFilename(snapshot: ProformaReportSnapshot): string {
// Ejemplo: factura-F2024-000123-FULANITO.pdf
return `factura-${snapshot.series}${snapshot.invoice_number}-${snapshot.recipient.name}.pdf`;
}
private buildCacheKey(snapshot: ProformaReportSnapshot): string {
// Versionado explícito para invalidaciones futuras
return ["proforma", snapshot.company_id, snapshot.series, snapshot.invoice_number, "v1"].join(
":"
);
}
}

View File

@ -14,9 +14,9 @@ export class ProformaDocumentPropertiesFactory
{ {
build(snapshot: ProformaReportSnapshot): IDocumentProperties { build(snapshot: ProformaReportSnapshot): IDocumentProperties {
return { return {
title: snapshot.reference, title: snapshot.reference ?? `Proforma ${snapshot.series}${snapshot.invoice_number}`,
subject: "proforma", subject: "proforma",
author: snapshot.company_slug, author: snapshot.company.legalName,
creator: "FactuGES ERP", creator: "FactuGES ERP",
}; };
} }

View File

@ -2,6 +2,5 @@ export * from "./proforma-items-report-snapshot-builder";
export * from "./proforma-report-item-snapshot.interface"; export * from "./proforma-report-item-snapshot.interface";
export * from "./proforma-report-snapshot.interface"; export * from "./proforma-report-snapshot.interface";
export * from "./proforma-report-snapshot-builder"; export * from "./proforma-report-snapshot-builder";
export * from "./proforma-report-snapshot-builder";
export * from "./proforma-report-tax-snapshot.interface"; export * from "./proforma-report-tax-snapshot.interface";
export * from "./proforma-tax-report-snapshot-builder"; export * from "./proforma-tax-report-snapshot-builder";

View File

@ -2,18 +2,21 @@ import { MoneyDTOHelper, PercentageDTOHelper, QuantityDTOHelper } from "@erp/cor
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api"; import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import type { ProformaFullSnapshot } from "../full"; import type { ProformaFullSnapshot } from "../full";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface"; import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
type ProformaItemReportSnapshotBuilderParams = ISnapshotBuilderParams & {
locale: string;
};
export interface IProformaItemReportSnapshotBuilder export interface IProformaItemReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot["items"], ProformaReportItemSnapshot[]> {} extends ISnapshotBuilder<ProformaFullSnapshot["items"], ProformaReportItemSnapshot[]> {}
export class ProformaItemReportSnapshotBuilder implements IProformaItemReportSnapshotBuilder { export class ProformaItemReportSnapshotBuilder implements IProformaItemReportSnapshotBuilder {
toOutput( toOutput(
items: ProformaFullSnapshot["items"], items: ProformaFullSnapshot["items"],
params?: ISnapshotBuilderParams params?: ProformaItemReportSnapshotBuilderParams
): ProformaReportItemSnapshot[] { ): ProformaReportItemSnapshot[] {
const locale = params?.locale as string; const locale = params?.locale ?? "es-ES";
const moneyOptions = { const moneyOptions = {
hideZeros: true, hideZeros: true,
@ -21,14 +24,18 @@ export class ProformaItemReportSnapshotBuilder implements IProformaItemReportSna
}; };
return items.map((item) => ({ return items.map((item) => ({
description: item.description, description: item.description ?? "",
quantity: QuantityDTOHelper.format(item.quantity, locale, { minimumFractionDigits: 0 }), quantity: item.quantity
unit_amount: MoneyDTOHelper.format(item.unit_amount, locale, moneyOptions), ? QuantityDTOHelper.format(item.quantity, locale, { minimumFractionDigits: 0 })
: "",
unit_amount: item.unit_amount ? MoneyDTOHelper.format(item.unit_amount, locale, moneyOptions) : "",
subtotal_amount: MoneyDTOHelper.format(item.subtotal_amount, locale, moneyOptions), subtotal_amount: MoneyDTOHelper.format(item.subtotal_amount, locale, moneyOptions),
discount_percentage: PercentageDTOHelper.format(item.discount_percentage, locale, { discount_percentage: item.item_discount_percentage
? PercentageDTOHelper.format(item.item_discount_percentage, locale, {
minimumFractionDigits: 0, minimumFractionDigits: 0,
}), })
discount_amount: MoneyDTOHelper.format(item.discount_amount, locale, moneyOptions), : "",
discount_amount: MoneyDTOHelper.format(item.item_discount_amount, locale, moneyOptions),
taxable_amount: MoneyDTOHelper.format(item.taxable_amount, locale, moneyOptions), taxable_amount: MoneyDTOHelper.format(item.taxable_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(item.taxes_amount, locale, moneyOptions), taxes_amount: MoneyDTOHelper.format(item.taxes_amount, locale, moneyOptions),
total_amount: MoneyDTOHelper.format(item.total_amount, locale, moneyOptions), total_amount: MoneyDTOHelper.format(item.total_amount, locale, moneyOptions),

View File

@ -2,12 +2,17 @@ import { MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api"; import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import { DateHelper } from "@repo/rdx-utils"; import { DateHelper } from "@repo/rdx-utils";
import type { CompanyReportProfile, ReportLocalization } from "../../models";
import type { ProformaFullSnapshot } from "../full"; import type { ProformaFullSnapshot } from "../full";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface"; import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
import type { ProformaReportSnapshot } from "./proforma-report-snapshot.interface"; import type { ProformaReportSnapshot } from "./proforma-report-snapshot.interface";
import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface"; import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface";
export type ProformaReportSnapshotBuilderParams = ISnapshotBuilderParams & {
company: CompanyReportProfile;
localization: ReportLocalization;
};
export interface IProformaReportSnapshotBuilder export interface IProformaReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot, ProformaReportSnapshot> {} extends ISnapshotBuilder<ProformaFullSnapshot, ProformaReportSnapshot> {}
@ -25,9 +30,20 @@ export class ProformaReportSnapshotBuilder implements IProformaReportSnapshotBui
toOutput( toOutput(
snapshot: ProformaFullSnapshot, snapshot: ProformaFullSnapshot,
params?: ISnapshotBuilderParams params?: ProformaReportSnapshotBuilderParams
): ProformaReportSnapshot { ): ProformaReportSnapshot {
const locale = params?.locale as string; const localization = params?.localization;
const company = params?.company;
if (!localization) {
throw new Error("Report localization is required");
}
if (!company) {
throw new Error("Company report profile is required");
}
const { locale } = localization;
const moneyOptions = { const moneyOptions = {
hideZeros: true, hideZeros: true,
@ -37,22 +53,23 @@ export class ProformaReportSnapshotBuilder implements IProformaReportSnapshotBui
return { return {
id: snapshot.id, id: snapshot.id,
company_id: snapshot.company_id, company_id: snapshot.company_id,
company_slug: "rodax",
invoice_number: snapshot.invoice_number, invoice_number: snapshot.invoice_number,
series: snapshot.series, series: snapshot.series,
status: snapshot.status, status: snapshot.status,
reference: snapshot.reference, reference: snapshot.reference,
localization,
company,
language_code: snapshot.language_code, language_code: snapshot.language_code,
currency_code: snapshot.currency_code, currency_code: snapshot.currency_code,
invoice_date: DateHelper.format(snapshot.invoice_date, locale), invoice_date: DateHelper.format(snapshot.invoice_date, locale),
payment_method: snapshot.payment_method?.payment_description ?? "", payment_method: snapshot.payment_method?.description ?? snapshot.payment_method?.name ?? "",
notes: snapshot.notes, notes: snapshot.notes,
recipient: { recipient: {
name: snapshot.recipient.name, name: snapshot.recipient.name ?? "",
tin: snapshot.recipient.tin, tin: snapshot.recipient.tin,
format_address: this.formatAddress(snapshot.recipient), format_address: this.formatAddress(snapshot.recipient),
}, },

View File

@ -1,25 +1,28 @@
import type { CompanyReportProfile, ReportLocalization } from "../../models";
import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface"; import type { ProformaReportItemSnapshot } from "./proforma-report-item-snapshot.interface";
import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface"; import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface";
export interface ProformaReportSnapshot { export interface ProformaReportSnapshot {
id: string; id: string;
company_id: string; company_id: string;
company_slug: string;
invoice_number: string; invoice_number: string;
series: string; series: string | null;
status: string; status: string;
reference: string; reference: string | null;
localization: ReportLocalization;
company: CompanyReportProfile;
language_code: string; language_code: string;
currency_code: string; currency_code: string;
invoice_date: string; invoice_date: string;
payment_method: string; payment_method: string;
notes: string; notes: string | null;
recipient: { recipient: {
name: string; name: string;
tin: string; tin: string | null;
format_address: string; format_address: string;
}; };

View File

@ -1,7 +1,12 @@
import { MoneyDTOHelper, PercentageDTOHelper } from "@erp/core"; import { MoneyDTOHelper, PercentageDTOHelper } from "@erp/core";
import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api"; import type { ISnapshotBuilder, ISnapshotBuilderParams } from "@erp/core/api";
import type { ProformaFullSnapshot, ProformaReportTaxSnapshot } from "../../application-models"; import type { ProformaFullSnapshot } from "../full";
import type { ProformaReportTaxSnapshot } from "./proforma-report-tax-snapshot.interface";
type ProformaTaxReportSnapshotBuilderParams = ISnapshotBuilderParams & {
locale: string;
};
export interface IProformaTaxReportSnapshotBuilder export interface IProformaTaxReportSnapshotBuilder
extends ISnapshotBuilder<ProformaFullSnapshot["taxes"], ProformaReportTaxSnapshot[]> {} extends ISnapshotBuilder<ProformaFullSnapshot["taxes"], ProformaReportTaxSnapshot[]> {}
@ -9,9 +14,9 @@ export interface IProformaTaxReportSnapshotBuilder
export class ProformaTaxReportSnapshotBuilder implements IProformaTaxReportSnapshotBuilder { export class ProformaTaxReportSnapshotBuilder implements IProformaTaxReportSnapshotBuilder {
toOutput( toOutput(
taxes: ProformaFullSnapshot["taxes"], taxes: ProformaFullSnapshot["taxes"],
params?: ISnapshotBuilderParams params?: ProformaTaxReportSnapshotBuilderParams
): ProformaReportTaxSnapshot[] { ): ProformaReportTaxSnapshot[] {
const locale = params?.locale as string; const locale = params?.locale ?? "es-ES";
const moneyOptions = { const moneyOptions = {
hideZeros: true, hideZeros: true,
@ -21,16 +26,20 @@ export class ProformaTaxReportSnapshotBuilder implements IProformaTaxReportSnaps
return taxes.map((tax) => ({ return taxes.map((tax) => ({
taxable_amount: MoneyDTOHelper.format(tax.taxable_amount, locale, moneyOptions), taxable_amount: MoneyDTOHelper.format(tax.taxable_amount, locale, moneyOptions),
iva_code: tax.iva_code, iva_code: tax.iva_code ?? "",
iva_percentage: PercentageDTOHelper.format(tax.iva_percentage, locale), iva_percentage: tax.iva_percentage
? PercentageDTOHelper.format(tax.iva_percentage, locale)
: "",
iva_amount: MoneyDTOHelper.format(tax.iva_amount, locale, moneyOptions), iva_amount: MoneyDTOHelper.format(tax.iva_amount, locale, moneyOptions),
rec_code: tax.rec_code, rec_code: tax.rec_code ?? "",
rec_percentage: PercentageDTOHelper.format(tax.rec_percentage, locale), rec_percentage: tax.rec_percentage ? PercentageDTOHelper.format(tax.rec_percentage, locale) : "",
rec_amount: MoneyDTOHelper.format(tax.rec_amount, locale, moneyOptions), rec_amount: MoneyDTOHelper.format(tax.rec_amount, locale, moneyOptions),
retention_code: tax.retention_code, retention_code: tax.retention_code ?? "",
retention_percentage: PercentageDTOHelper.format(tax.retention_percentage, locale), retention_percentage: tax.retention_percentage
? PercentageDTOHelper.format(tax.retention_percentage, locale)
: "",
retention_amount: MoneyDTOHelper.format(tax.retention_amount, locale, moneyOptions), retention_amount: MoneyDTOHelper.format(tax.retention_amount, locale, moneyOptions),
taxes_amount: MoneyDTOHelper.format(tax.taxes_amount, locale, moneyOptions), taxes_amount: MoneyDTOHelper.format(tax.taxes_amount, locale, moneyOptions),

View File

@ -4,5 +4,6 @@ export * from "./create-proforma.use-case";
export * from "./get-proforma-by-id.use-case"; export * from "./get-proforma-by-id.use-case";
export * from "./issue-proforma.use-case"; export * from "./issue-proforma.use-case";
export * from "./list-proformas.use-case"; export * from "./list-proformas.use-case";
export * from "./report-proforma.use-case"; export * from "./preview-proforma-report.use-case";
export * from "./report-proforma-pdf.use-case";
export * from "./update-proforma-by-id.use-case"; export * from "./update-proforma-by-id.use-case";

View File

@ -0,0 +1,124 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanyReportProfileNotFoundError } from "../errors";
import type {
ICompanyReportProfileFinder,
IProformaFinder,
IProformaFullReadModelAssembler,
ProformaDocumentGeneratorService,
} from "../services";
import type { ReportLocalization } from "../models";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
import type { IProformaReportSnapshotBuilder } from "../snapshot-builders/report";
type PreviewProformaReportUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
export class PreviewProformaReportUseCase {
public constructor(
private readonly deps: {
finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager;
}
) {}
public execute(params: PreviewProformaReportUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
return this.deps.transactionManager.complete(async (transaction) => {
try {
const proformaResult = await this.deps.finder.findProformaById(
companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
proforma: proformaResult.data,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
const companyProfileResult = await this.deps.companyReportProfileFinder.findByCompanyId(
companyId,
transaction
);
if (companyProfileResult.isFailure) {
return Result.fail(companyProfileResult.error);
}
if (companyProfileResult.data.isNone()) {
return Result.fail(new CompanyReportProfileNotFoundError(companyId.toString()));
}
const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data);
const localization = this.resolveLocalization({
languageCode: fullSnapshot.language_code,
currencyCode: fullSnapshot.currency_code,
companyLocale: companyProfileResult.data.unwrap().locale,
companyCurrencyCode: companyProfileResult.data.unwrap().currencyCode,
});
const reportSnapshot = this.deps.reportSnapshotBuilder.toOutput(fullSnapshot, {
localization,
company: companyProfileResult.data.unwrap(),
});
const documentResult = await this.deps.documentService.generate(reportSnapshot, {
companySlug: reportSnapshot.company.slug,
});
if (documentResult.isFailure) {
return Result.fail(documentResult.error);
}
return Result.ok({
payload: documentResult.data.payload.toString("utf-8"),
mimeType: "text/html; charset=utf-8" as const,
filename: documentResult.data.filename,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
private resolveLocalization(params: {
languageCode: string;
currencyCode: string;
companyLocale: Maybe<string>;
companyCurrencyCode: Maybe<string>;
}): ReportLocalization {
return {
languageCode: params.languageCode || "es",
locale: params.companyLocale.getOrUndefined() ?? "es-ES",
currencyCode: params.currencyCode || params.companyCurrencyCode.getOrUndefined() || "EUR",
};
}
}

View File

@ -0,0 +1,124 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { CompanyReportProfileNotFoundError } from "../errors";
import type {
ICompanyReportProfileFinder,
IProformaFinder,
IProformaFullReadModelAssembler,
ProformaDocumentGeneratorService,
} from "../services";
import type { ReportLocalization } from "../models";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
import type { IProformaReportSnapshotBuilder } from "../snapshot-builders/report";
type ReportProformaPdfUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
};
export class ReportProformaPdfUseCase {
public constructor(
private readonly deps: {
finder: IProformaFinder;
companyReportProfileFinder: ICompanyReportProfileFinder;
fullReadModelAssembler: IProformaFullReadModelAssembler;
fullSnapshotBuilder: IProformaFullSnapshotBuilder;
reportSnapshotBuilder: IProformaReportSnapshotBuilder;
documentService: ProformaDocumentGeneratorService;
transactionManager: ITransactionManager;
}
) {}
public execute(params: ReportProformaPdfUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
return this.deps.transactionManager.complete(async (transaction) => {
try {
const proformaResult = await this.deps.finder.findProformaById(
companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const readModelResult = await this.deps.fullReadModelAssembler.assemble({
companyId,
proforma: proformaResult.data,
transaction,
});
if (readModelResult.isFailure) {
return Result.fail(readModelResult.error);
}
const companyProfileResult = await this.deps.companyReportProfileFinder.findByCompanyId(
companyId,
transaction
);
if (companyProfileResult.isFailure) {
return Result.fail(companyProfileResult.error);
}
if (companyProfileResult.data.isNone()) {
return Result.fail(new CompanyReportProfileNotFoundError(companyId.toString()));
}
const fullSnapshot = this.deps.fullSnapshotBuilder.toOutput(readModelResult.data);
const localization = this.resolveLocalization({
languageCode: fullSnapshot.language_code,
currencyCode: fullSnapshot.currency_code,
companyLocale: companyProfileResult.data.unwrap().locale,
companyCurrencyCode: companyProfileResult.data.unwrap().currencyCode,
});
const reportSnapshot = this.deps.reportSnapshotBuilder.toOutput(fullSnapshot, {
localization,
company: companyProfileResult.data.unwrap(),
});
const documentResult = await this.deps.documentService.generate(reportSnapshot, {
companySlug: reportSnapshot.company.slug,
});
if (documentResult.isFailure) {
return Result.fail(documentResult.error);
}
return Result.ok({
payload: documentResult.data.payload,
mimeType: "application/pdf" as const,
filename: documentResult.data.filename,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
private resolveLocalization(params: {
languageCode: string;
currencyCode: string;
companyLocale: Maybe<string>;
companyCurrencyCode: Maybe<string>;
}): ReportLocalization {
return {
languageCode: params.languageCode || "es",
locale: params.companyLocale.getOrUndefined() ?? "es-ES",
currencyCode: params.currencyCode || params.companyCurrencyCode.getOrUndefined() || "EUR",
};
}
}

View File

@ -1,80 +0,0 @@
import { type ITransactionManager, type RendererFormat, logger } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { IProformaFinder, ProformaDocumentGeneratorService } from "../services";
import type { IProformaFullSnapshotBuilder } from "../snapshot-builders";
import type { IProformaReportSnapshotBuilder } from "../snapshot-builders/report";
type ReportProformaUseCaseInput = {
companyId: UniqueID;
proforma_id: string;
format: RendererFormat;
};
export class ReportProformaUseCase {
constructor(
private readonly finder: IProformaFinder,
private readonly fullSnapshotBuilder: IProformaFullSnapshotBuilder,
private readonly reportSnapshotBuilder: IProformaReportSnapshotBuilder,
private readonly documentGenerationService: ProformaDocumentGeneratorService,
private readonly transactionManager: ITransactionManager
) {}
public async execute(params: ReportProformaUseCaseInput) {
const { proforma_id, companyId } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
return this.transactionManager.complete(async (transaction) => {
try {
const proformaResult = await this.finder.findProformaById(
companyId,
proformaId,
transaction
);
if (proformaResult.isFailure) {
return Result.fail(proformaResult.error);
}
const invoice = proformaResult.data;
// Snapshot completo de la entidad
const fullSnapshot = this.fullSnapshotBuilder.toOutput(invoice);
// Snapshot para informe a partir del anterior
const reportSnapshot = this.reportSnapshotBuilder.toOutput(fullSnapshot, {
languageCode: invoice.languageCode,
});
// Llamar al servicio y que se apañe
const documentResult = await this.documentGenerationService.generate(reportSnapshot, {
companySlug: "rodax",
});
if (documentResult.isFailure) {
return Result.fail(documentResult.error);
}
11;
// 5. Devolver artefacto firmado
return Result.ok({
payload: documentResult.data.payload,
mimeType: "application/pdf",
filename: documentResult.data.filename,
});
} catch (error: unknown) {
const err = error as Error;
logger.error(err.message, { label: "ReportProformaUseCase.execure" });
return Result.fail(err);
}
});
}
}

View File

@ -1,2 +0,0 @@
export * from "./report-proforma.use-case";
export * from "./reporter";

View File

@ -1,67 +0,0 @@
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { CustomerInvoiceApplicationService } from "../../../services/customer-invoice-application.service";
type ReportProformaUseCaseInput = {
companyId: UniqueID;
companySlug: string;
proforma_id: string;
format: "pdf" | "html";
};
export class ReportProformaUseCase {
constructor(
private readonly service: CustomerInvoiceApplicationService,
private readonly transactionManager: ITransactionManager,
private readonly presenterRegistry: IPresenterRegistry
) {}
public async execute(params: ReportProformaUseCaseInput) {
const { proforma_id, companySlug, companyId, format } = params;
const idOrError = UniqueID.create(proforma_id);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const proformaId = idOrError.data;
const reportPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format,
});
return this.transactionManager.complete(async (transaction) => {
try {
const proformaOrError = await this.service.getProformaByIdInCompany(
companyId,
proformaId,
transaction
);
if (proformaOrError.isFailure) {
return Result.fail(proformaOrError.error);
}
const proforma = proformaOrError.data;
const reportData = await reportPresenter.toOutput(proforma, { companySlug });
if (format === "html") {
return Result.ok({
data: String(reportData),
filename: undefined,
});
}
return Result.ok({
data: reportData as Buffer<ArrayBuffer>,
filename: `proforma-${proforma.invoiceNumber}.pdf`,
});
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -1,32 +0,0 @@
import { TemplatePresenter } from "@erp/core/api";
import type { Proforma } from "../../../../../domain";
import type { ProformaFullPresenter, ProformaReportPresenter } from "../../../../snapshot-builders";
export class ProformaReportHTMLPresenter extends TemplatePresenter {
toOutput(proforma: Proforma, params: { companySlug: string }): string {
const { companySlug } = params;
const dtoPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "FULL",
}) as ProformaFullPresenter;
const prePresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format: "JSON",
}) as ProformaReportPresenter;
const invoiceDTO = dtoPresenter.toOutput(proforma);
const prettyDTO = prePresenter.toOutput(invoiceDTO);
// Obtener y compilar la plantilla HTML
const template = this.templateResolver.compileTemplate(
"customer-invoices",
companySlug,
"proforma.hbs"
);
return template(prettyDTO);
}
}

View File

@ -1,76 +0,0 @@
import { Presenter } from "@erp/core/api";
import puppeteer from "puppeteer";
import type { Proforma } from "../../../../../domain";
import type { ProformaReportHTMLPresenter } from "./proforma.report.html";
// https://plnkr.co/edit/lWk6Yd?preview
// https://latenode.com/es/blog/web-automation-scraping/puppeteer-fundamentals-setup/complete-guide-to-pdf-generation-with-puppeteer-from-simple-documents-to-complex-reports
export class ProformaReportPDFPresenter extends Presenter<Proforma, Promise<Buffer<ArrayBuffer>>> {
async toOutput(
proforma: Proforma,
params: { companySlug: string }
): Promise<Buffer<ArrayBuffer>> {
try {
const htmlPresenter = this.presenterRegistry.getPresenter({
resource: "proforma",
projection: "REPORT",
format: "HTML",
}) as ProformaReportHTMLPresenter;
const htmlData = htmlPresenter.toOutput(proforma, params);
// Generar el PDF con Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-extensions",
"--font-render-hinting=medium",
],
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
});
const page = await browser.newPage();
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(60000);
await page.setContent(htmlData, {
waitUntil: "networkidle2",
});
// Espera extra opcional si hay imágenes base64 muy grandes
await page.waitForNetworkIdle({ idleTime: 200, timeout: 5000 });
const reportPDF = await page.pdf({
format: "A4",
margin: {
bottom: 0,
left: 0,
right: 0,
top: 0,
},
landscape: false,
preferCSSPageSize: true,
omitBackground: false,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: "<div />",
footerTemplate:
'<div style="text-align: center;width: 297mm;font-size: 10px;">Página <span style="margin-right: 1cm"><span class="pageNumber"></span> de <span class="totalPages"></span></span></span></div>',
});
await browser.close();
return Buffer.from(reportPDF);
} catch (err: unknown) {
console.error(err);
throw err as Error;
}
}
}

View File

@ -0,0 +1,51 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Maybe, Result, type Result as ResultType } from "@repo/rdx-utils";
import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
import type { CompanyReportProfile, ICompanyReportProfileFinder } from "../../../application";
export class CompanyReportProfileFinder implements ICompanyReportProfileFinder {
public constructor(
private readonly deps: {
companiesServices: ICompanyPublicServices;
}
) {}
public async findByCompanyId(
companyId: UniqueID,
transaction?: unknown
): Promise<ResultType<Maybe<CompanyReportProfile>, Error>> {
const result = await this.deps.companiesServices.finder.findById({
companyId,
transaction,
});
if (result.isFailure) {
return Result.fail(result.error);
}
if (result.data.isNone()) {
return Result.ok(Maybe.none());
}
const company = result.data.unwrap();
const profile: CompanyReportProfile = {
companyId: company.id,
slug: company.slug,
legalName: company.legalName,
tradeName: Maybe.fromNullable(company.tradeName ?? undefined),
tin: Maybe.fromNullable(company.tin ?? undefined),
email: Maybe.fromNullable(company.email ?? undefined),
phone: Maybe.fromNullable(company.phone ?? undefined),
website: Maybe.fromNullable(company.website ?? undefined),
locale: Maybe.none(),
currencyCode: Maybe.none(),
logo: Maybe.none(),
address: Maybe.none(),
version: Maybe.none(),
};
return Result.ok(Maybe.some(profile));
}
}

View File

@ -1,27 +1,28 @@
import { type ModuleParams, buildCoreDocumentsDI } from "@erp/core/api"; import { type ModuleParams, buildCoreDocumentsDI } from "@erp/core/api";
import { import {
ProformaDocumentPipelineFactory, ProformaHtmlPreviewPipelineFactory,
type ProformaDocumentPipelineFactoryDeps, type ProformaHtmlPreviewPipelineFactoryDeps,
ProformaPdfDocumentPipelineFactory,
type ProformaPdfDocumentPipelineFactoryDeps,
} from "../documents"; } from "../documents";
export const buildProformaDocumentService = (params: ModuleParams) => { export const buildProformaDocumentServices = (params: ModuleParams) => {
const { documentRenderers, documentSigning, documentStorage } = buildCoreDocumentsDI(params); const { documentRenderers, documentStorage } = buildCoreDocumentsDI(params);
const pipelineDeps: ProformaDocumentPipelineFactoryDeps = { const pdfPipelineDeps: ProformaPdfDocumentPipelineFactoryDeps = {
fastReportRenderer: documentRenderers.fastReportRenderer, fastReportRenderer: documentRenderers.fastReportRenderer,
//
signingContextResolver: documentSigning.signingContextResolver,
documentSigningService: documentSigning.signingService,
//
documentStorage: documentStorage.storage, documentStorage: documentStorage.storage,
templateResolver: documentRenderers.fastReportTemplateResolver, templateResolver: documentRenderers.fastReportTemplateResolver,
}; };
const documentGeneratorPipeline = ProformaDocumentPipelineFactory.create(pipelineDeps); const htmlPreviewPipelineDeps: ProformaHtmlPreviewPipelineFactoryDeps = {
fastReportRenderer: documentRenderers.fastReportRenderer,
return documentGeneratorPipeline; templateResolver: documentRenderers.fastReportTemplateResolver,
};
return {
pdf: ProformaPdfDocumentPipelineFactory.create(pdfPipelineDeps),
htmlPreview: ProformaHtmlPreviewPipelineFactory.create(htmlPreviewPipelineDeps),
};
}; };

View File

@ -65,6 +65,7 @@ export function buildProformaPublicServices(
taxDefinitionFinder: catalogs.taxDefinition.finder, taxDefinitionFinder: catalogs.taxDefinition.finder,
taxRegimeFinder: catalogs.taxRegime.finder, taxRegimeFinder: catalogs.taxRegime.finder,
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,
paymentTermFinder: catalogs.paymentTerm.finder,
}); });
const readModelAssemblers = buildProformaReadModelAssemblers({ const readModelAssemblers = buildProformaReadModelAssemblers({

View File

@ -1,4 +1,5 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
import { import {
type ChangeStatusProformaUseCase, type ChangeStatusProformaUseCase,
@ -7,13 +8,15 @@ import {
type IIssuedInvoicePublicServices, type IIssuedInvoicePublicServices,
type IssueProformaUseCase, type IssueProformaUseCase,
type ListProformasUseCase, type ListProformasUseCase,
type ReportProformaUseCase, type PreviewProformaReportUseCase,
type ReportProformaPdfUseCase,
type UpdateProformaByIdUseCase, type UpdateProformaByIdUseCase,
buildChangeStatusProformaUseCase, buildChangeStatusProformaUseCase,
buildCreateProformaUseCase, buildCreateProformaUseCase,
buildGetProformaByIdUseCase, buildGetProformaByIdUseCase,
buildIssueProformaUseCase, buildIssueProformaUseCase,
buildListProformasUseCase, buildListProformasUseCase,
buildPreviewProformaReportUseCase,
buildProformaCatalogResolvers, buildProformaCatalogResolvers,
buildProformaCreator, buildProformaCreator,
buildProformaFinder, buildProformaFinder,
@ -25,11 +28,12 @@ import {
buildProformaStatusChanger, buildProformaStatusChanger,
buildProformaToIssuedInvoicePropsConverter, buildProformaToIssuedInvoicePropsConverter,
buildProformaUpdater, buildProformaUpdater,
buildReportProformaUseCase, buildReportProformaPdfUseCase,
buildUpdateProformaUseCase, buildUpdateProformaUseCase,
} from "../../../application"; } from "../../../application";
import { buildProformaDocumentService } from "./proforma-documents.di"; import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
import { buildProformaDocumentServices } from "./proforma-documents.di";
import { buildProformaNumberGenerator } from "./proforma-number-generator.di"; import { buildProformaNumberGenerator } from "./proforma-number-generator.di";
import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di"; import { buildProformaPersistenceMappers } from "./proforma-persistence-mappers.di";
import { buildProformaRepository } from "./proforma-repositories.di"; import { buildProformaRepository } from "./proforma-repositories.di";
@ -39,7 +43,8 @@ export type ProformasInternalDeps = {
useCases: { useCases: {
listProformas: () => ListProformasUseCase; listProformas: () => ListProformasUseCase;
getProformaById: () => GetProformaByIdUseCase; getProformaById: () => GetProformaByIdUseCase;
reportProforma: () => ReportProformaUseCase; reportProformaPdf: () => ReportProformaPdfUseCase;
previewProformaReport: () => PreviewProformaReportUseCase;
createProforma: () => CreateProformaUseCase; createProforma: () => CreateProformaUseCase;
issueProforma: (publicServices: { issueProforma: (publicServices: {
issuedInvoiceServices: IIssuedInvoicePublicServices; issuedInvoiceServices: IIssuedInvoicePublicServices;
@ -58,6 +63,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const { database } = params; const { database } = params;
const catalogs = resolveProformaCatalogsDeps(params); const catalogs = resolveProformaCatalogsDeps(params);
const companiesServices = params.getService<ICompanyPublicServices>("companies:general");
const transactionManager = buildTransactionManager(database); const transactionManager = buildTransactionManager(database);
const persistenceMappers = buildProformaPersistenceMappers(); const persistenceMappers = buildProformaPersistenceMappers();
@ -113,7 +119,10 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
paymentMethodFinder: catalogs.paymentMethod.finder, paymentMethodFinder: catalogs.paymentMethod.finder,
}); });
const documentGeneratorPipeline = buildProformaDocumentService(params); const companyReportProfileFinder = new CompanyReportProfileFinder({
companiesServices,
});
const documentGeneratorServices = buildProformaDocumentServices(params);
return { return {
useCases: { useCases: {
@ -150,13 +159,25 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager, transactionManager,
}), }),
reportProforma: () => reportProformaPdf: () =>
buildReportProformaUseCase({ buildReportProformaPdfUseCase({
finder, finder,
companyReportProfileFinder,
fullReadModelAssembler: readModelAssemblers.full, fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
reportSnapshotBuilder: snapshotBuilders.report, reportSnapshotBuilder: snapshotBuilders.report,
documentService: documentGeneratorPipeline, documentService: documentGeneratorServices.pdf,
transactionManager,
}),
previewProformaReport: () =>
buildPreviewProformaReportUseCase({
finder,
companyReportProfileFinder,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
reportSnapshotBuilder: snapshotBuilders.report,
documentService: documentGeneratorServices.htmlPreview,
transactionManager, transactionManager,
}), }),

View File

@ -1 +1,2 @@
export * from "./proforma-document-pipeline-factory.ts"; export * from "./proforma-html-preview-pipeline-factory.ts";
export * from "./proforma-pdf-document-pipeline-factory.ts";

View File

@ -1,71 +0,0 @@
import {
DocumentGenerationService,
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
type IDocumentPostProcessor,
type IDocumentSideEffect,
type IDocumentSigningService,
type IDocumentStorage,
type ISigningContextResolver,
} from "@erp/core/api";
import {
type ProformaDocumentGeneratorService,
ProformaDocumentMetadataFactory,
ProformaDocumentPropertiesFactory,
type ProformaReportSnapshot,
} from "../../../../application";
import { ProformaSignedDocumentCachePreProcessor } from "../pre-processors";
import { ProformaDocumentRenderer } from "../renderers";
import { PersistProformaDocumentSideEffect } from "../side-effects";
/**
* Factory de pipeline de generación de documentos
* para facturas emitidas (Issued Invoice).
*/
export interface ProformaDocumentPipelineFactoryDeps {
// Core / Infra
fastReportRenderer: FastReportRenderer;
templateResolver: FastReportTemplateResolver;
signingContextResolver: ISigningContextResolver;
documentSigningService: IDocumentSigningService;
documentStorage: IDocumentStorage;
}
export class ProformaDocumentPipelineFactory {
static create(deps: ProformaDocumentPipelineFactoryDeps): ProformaDocumentGeneratorService {
// 1. Pre-processors (cache firmado)
const preProcessors = [new ProformaSignedDocumentCachePreProcessor(deps.documentStorage)];
// 2. Renderer (FastReport)
const renderer = new ProformaDocumentRenderer(deps.fastReportRenderer, deps.templateResolver);
// 3) Metadata and properties factory (Application)
const metadataFactory = new ProformaDocumentMetadataFactory();
const propertiesFactory = new ProformaDocumentPropertiesFactory();
// 3) Firma real (Core / Infra)
const postProcessor: IDocumentPostProcessor = new DocumentPostProcessorChain([
// Aquí podrían ir más post-procesadores, como uno de validación o similar
]);
// 4. Side-effects (persistencia best-effort)
const sideEffects: IDocumentSideEffect[] = [
new PersistProformaDocumentSideEffect(deps.documentStorage),
];
// 5. Pipeline final
return new DocumentGenerationService<ProformaReportSnapshot>({
renderer,
preProcessors,
postProcessor,
sideEffects,
metadataFactory,
propertiesFactory,
});
}
}

View File

@ -0,0 +1,40 @@
import {
DocumentGenerationService,
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
} from "@erp/core/api";
import {
type ProformaDocumentGeneratorService,
ProformaDocumentPropertiesFactory,
type ProformaReportSnapshot,
} from "../../../../application";
import { ProformaHtmlPreviewRenderer } from "../renderers";
import { ProformaHtmlPreviewDocumentMetadataFactory } from "../services/proforma-html-preview-document-metadata-factory";
export interface ProformaHtmlPreviewPipelineFactoryDeps {
fastReportRenderer: FastReportRenderer;
templateResolver: FastReportTemplateResolver;
}
export class ProformaHtmlPreviewPipelineFactory {
static create(deps: ProformaHtmlPreviewPipelineFactoryDeps): ProformaDocumentGeneratorService {
const renderer = new ProformaHtmlPreviewRenderer(
deps.fastReportRenderer,
deps.templateResolver
);
const metadataFactory = new ProformaHtmlPreviewDocumentMetadataFactory();
const propertiesFactory = new ProformaDocumentPropertiesFactory();
const postProcessor = new DocumentPostProcessorChain([]);
return new DocumentGenerationService<ProformaReportSnapshot>({
renderer,
preProcessors: [],
postProcessor,
sideEffects: [],
metadataFactory,
propertiesFactory,
});
}
}

View File

@ -0,0 +1,49 @@
import {
DocumentGenerationService,
DocumentPostProcessorChain,
type FastReportRenderer,
type FastReportTemplateResolver,
type IDocumentSideEffect,
type IDocumentStorage,
} from "@erp/core/api";
import {
type ProformaDocumentGeneratorService,
ProformaDocumentPropertiesFactory,
type ProformaReportSnapshot,
} from "../../../../application";
import { ProformaPdfCachePreProcessor } from "../pre-processors";
import { ProformaPdfDocumentRenderer } from "../renderers";
import { ProformaPdfDocumentMetadataFactory } from "../services/proforma-pdf-document-metadata-factory";
import { PersistProformaPdfSideEffect } from "../side-effects";
export interface ProformaPdfDocumentPipelineFactoryDeps {
fastReportRenderer: FastReportRenderer;
templateResolver: FastReportTemplateResolver;
documentStorage: IDocumentStorage;
}
export class ProformaPdfDocumentPipelineFactory {
static create(deps: ProformaPdfDocumentPipelineFactoryDeps): ProformaDocumentGeneratorService {
const preProcessors = [new ProformaPdfCachePreProcessor(deps.documentStorage)];
const renderer = new ProformaPdfDocumentRenderer(
deps.fastReportRenderer,
deps.templateResolver
);
const metadataFactory = new ProformaPdfDocumentMetadataFactory();
const propertiesFactory = new ProformaDocumentPropertiesFactory();
const postProcessor = new DocumentPostProcessorChain([]);
const sideEffects: IDocumentSideEffect[] = [
new PersistProformaPdfSideEffect(deps.documentStorage),
];
return new DocumentGenerationService<ProformaReportSnapshot>({
renderer,
preProcessors,
postProcessor,
sideEffects,
metadataFactory,
propertiesFactory,
});
}
}

View File

@ -1 +1 @@
export * from "./proforma-document-cache-pre-processor"; export * from "./proforma-pdf-cache-pre-processor";

View File

@ -1,71 +0,0 @@
import {
DocumentStorageKeyFactory,
type IDocument,
type IDocumentMetadata,
type IDocumentPreProcessor,
type IDocumentStorage,
logger,
} from "@erp/core/api";
/**
* Pre-processor de cache técnico para documentos firmados
* de facturas emitidas.
*
* - Best-effort
* - Nunca rompe el flujo
* - Invalida cache corrupto
*/
export class ProformaSignedDocumentCachePreProcessor implements IDocumentPreProcessor {
constructor(private readonly docStorage: IDocumentStorage) {}
async tryResolve(metadata: IDocumentMetadata): Promise<IDocument | null> {
const metadataRecord = metadata as unknown as Record<string, unknown>;
try {
const { paths, storageKey } = DocumentStorageKeyFactory.fromMetadataRecord(metadataRecord);
if (!storageKey) {
return null;
}
const exists = await this.docStorage.existsKeyStorage(storageKey, paths);
if (!exists) {
return null;
}
logger.info(`✅ Found Server cached document for key ${storageKey}`);
const document = await this.docStorage.readDocument(storageKey, paths);
if (!this.isValid(document)) {
logger.warn(`Corrupted or invalid cached document for key ${storageKey}`, {
label: "ProformaSignedDocumentCachePreProcessor",
});
return null;
}
return document;
} catch {
// best-effort: cualquier fallo se trata como cache miss
return null;
}
}
/**
* Validación mínima de integridad.
* No valida firma criptográfica.
*/
private isValid(document: IDocument | null): boolean {
if (!document) return false;
if (!document.payload || document.payload.length === 0) {
return false;
}
if (document.mimeType !== "application/pdf") {
return false;
}
return true;
}
}

View File

@ -0,0 +1,34 @@
import {
DocumentStorageKeyFactory,
type IDocument,
type IDocumentMetadata,
type IDocumentPreProcessor,
type IDocumentStorage,
} from "@erp/core/api";
export class ProformaPdfCachePreProcessor implements IDocumentPreProcessor {
public constructor(private readonly docStorage: IDocumentStorage) {}
public async tryResolve(metadata: IDocumentMetadata): Promise<IDocument | null> {
const metadataRecord = metadata as unknown as Record<string, unknown>;
const { paths, storageKey } = DocumentStorageKeyFactory.fromMetadataRecord(metadataRecord);
if (!storageKey) {
return null;
}
const exists = await this.docStorage.existsKeyStorage(storageKey, paths);
if (!exists) {
return null;
}
const document = await this.docStorage.readDocument(storageKey, paths);
if (!document || document.mimeType !== "application/pdf" || document.payload.length === 0) {
return null;
}
return document;
}
}

View File

@ -1 +1,2 @@
export * from "./proforma-document-renderer.ts"; export * from "./proforma-html-preview-renderer.ts";
export * from "./proforma-pdf-document-renderer.ts";

View File

@ -1,72 +0,0 @@
import type {
FastReportRenderer,
FastReportTemplateResolver,
IDocument,
IDocumentProperties,
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
/**
* Adaptador Application Infra para la generación del documento
* PDF de una factura emitida (Issued Invoice).
*
* - Recibe snapshot de report
* - Invoca FastReportRenderer
* - Devuelve IDocument
*
* NO captura errores: FastReportError se propaga.
*/
export type ProformaDocumentRenderParams = {
companySlug: string;
format: string;
languageCode: string;
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaDocumentRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver
) {}
async render(
snapshot: ProformaReportSnapshot,
params: ProformaDocumentRenderParams
): Promise<IDocument> {
const { companySlug, format, languageCode, filename, mimeType, properties } = params;
// Template
const templatePath = this.templateResolver.resolveTemplatePath({
module: "customer-invoices",
companySlug,
languageCode,
templateFilename: "proforma.frx",
});
const output = await this.fastReportRenderer.render({
templatePath,
inputData: snapshot,
format,
properties,
});
return {
payload: this.normalizePayload(output.payload),
mimeType,
filename,
};
}
/**
* Normaliza la salida de FastReport a Buffer.
* FastReport puede devolver string o Buffer.
*/
private normalizePayload(payload: Buffer | string): Buffer {
return Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
}
}

View File

@ -0,0 +1,50 @@
import type {
FastReportRenderer,
FastReportTemplateResolver,
IDocument,
IDocumentProperties,
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
type ProformaHtmlPreviewRenderParams = {
companySlug: string;
format: string;
languageCode: string;
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaHtmlPreviewRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
public constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver
) {}
public async render(
snapshot: ProformaReportSnapshot,
params: ProformaHtmlPreviewRenderParams
): Promise<IDocument> {
const templatePath = this.templateResolver.resolveTemplatePath({
module: "customer-invoices",
companySlug: params.companySlug,
languageCode: params.languageCode,
templateFilename: "proforma.frx",
});
const output = await this.fastReportRenderer.render({
templatePath,
inputData: snapshot,
format: params.format,
properties: params.properties,
});
return {
payload: Buffer.isBuffer(output.payload) ? output.payload : Buffer.from(output.payload),
mimeType: params.mimeType,
filename: params.filename,
};
}
}

View File

@ -0,0 +1,50 @@
import type {
FastReportRenderer,
FastReportTemplateResolver,
IDocument,
IDocumentProperties,
IDocumentRenderer,
} from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../../application";
type ProformaPdfDocumentRenderParams = {
companySlug: string;
format: string;
languageCode: string;
filename: string;
mimeType: string;
properties: IDocumentProperties;
};
export class ProformaPdfDocumentRenderer implements IDocumentRenderer<ProformaReportSnapshot> {
public constructor(
private readonly fastReportRenderer: FastReportRenderer,
private readonly templateResolver: FastReportTemplateResolver
) {}
public async render(
snapshot: ProformaReportSnapshot,
params: ProformaPdfDocumentRenderParams
): Promise<IDocument> {
const templatePath = this.templateResolver.resolveTemplatePath({
module: "customer-invoices",
companySlug: params.companySlug,
languageCode: params.languageCode,
templateFilename: "proforma.frx",
});
const output = await this.fastReportRenderer.render({
templatePath,
inputData: snapshot,
format: params.format,
properties: params.properties,
});
return {
payload: Buffer.isBuffer(output.payload) ? output.payload : Buffer.from(output.payload),
mimeType: params.mimeType,
filename: params.filename,
};
}
}

View File

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

View File

@ -0,0 +1,48 @@
import type { IDocumentMetadata, IDocumentMetadataFactory } from "@erp/core/api";
import type { ProformaReportSnapshot } from "../../../../application";
export class ProformaPdfDocumentMetadataFactory
implements IDocumentMetadataFactory<ProformaReportSnapshot>
{
build(snapshot: ProformaReportSnapshot): IDocumentMetadata {
return {
documentType: "proforma",
documentId: snapshot.id,
companyId: snapshot.company_id,
companySlug: snapshot.company.slug,
format: "PDF",
languageCode: snapshot.localization.languageCode,
filename: `proforma-${snapshot.series}${snapshot.invoice_number}.pdf`,
storageKey: this.buildStorageKey(snapshot),
};
}
private buildStorageKey(snapshot: ProformaReportSnapshot): string | undefined {
if (snapshot.status === "draft") {
return undefined;
}
// La cache de proformas no draft es segura respecto al contenido de la proforma
// porque el dominio no permite modificar proformas fuera de draft. Queda pendiente
// invalidar por version de plantilla/branding cuando esos datos existan.
const keyParts = [
"proforma",
snapshot.company_id,
snapshot.id,
snapshot.status,
"pdf",
snapshot.localization.languageCode,
snapshot.localization.currencyCode,
snapshot.company.slug,
];
const companyVersion = snapshot.company.version.getOrUndefined();
if (companyVersion) {
keyParts.push(companyVersion);
}
return keyParts.join(":");
}
}

View File

@ -1 +1 @@
export * from "./persist-proforma-document-side-effect"; export * from "./persist-proforma-pdf-side-effect";

View File

@ -1,27 +0,0 @@
import type {
IDocument,
IDocumentMetadata,
IDocumentSideEffect,
IDocumentStorage,
} from "@erp/core/api";
/**
* Side-effect de persistencia best-effort del documento final
* de una factura emitida.
*
* - Nunca rompe el flujo
* - Usa cacheKey/metadata para decidir la clave
*/
export class PersistProformaDocumentSideEffect implements IDocumentSideEffect {
constructor(private readonly storage: IDocumentStorage) {}
async execute(document: IDocument, metadata: IDocumentMetadata): Promise<void> {
// Si no hay cacheKey, no se persiste
if (!metadata.storageKey) {
return;
}
// Persistencia best-effort
await this.storage.saveDocument(document, metadata as unknown as Record<string, unknown>);
}
}

View File

@ -0,0 +1,18 @@
import type {
IDocument,
IDocumentMetadata,
IDocumentSideEffect,
IDocumentStorage,
} from "@erp/core/api";
export class PersistProformaPdfSideEffect implements IDocumentSideEffect {
public constructor(private readonly storage: IDocumentStorage) {}
public async execute(document: IDocument, metadata: IDocumentMetadata): Promise<void> {
if (!metadata.storageKey) {
return;
}
await this.storage.saveDocument(document, metadata as unknown as Record<string, unknown>);
}
}

View File

@ -4,5 +4,6 @@
export * from "./get-proforma.controller"; export * from "./get-proforma.controller";
export * from "./issue-proforma.controller"; export * from "./issue-proforma.controller";
export * from "./list-proformas.controller"; export * from "./list-proformas.controller";
export * from "./report-proforma.controller"; export * from "./preview-proforma-report.controller";
export * from "./report-proforma-pdf.controller";
export * from "./update-proforma.controller"; export * from "./update-proforma.controller";

View File

@ -0,0 +1,41 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { PreviewProformaReportUseCase } from "../../../../application";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class PreviewProformaReportController extends ExpressController {
public constructor(private readonly useCase: PreviewProformaReportUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
const result = await this.useCase.execute({
proforma_id,
companyId,
});
return result.match(
({ payload }) => this.downloadHTML(payload),
(err) => this.handleError(err)
);
}
}

View File

@ -0,0 +1,41 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { ReportProformaPdfUseCase } from "../../../../application";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class ReportProformaPdfController extends ExpressController {
public constructor(private readonly useCase: ReportProformaPdfUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
const result = await this.useCase.execute({
proforma_id,
companyId,
});
return result.match(
({ payload, filename }) => this.downloadPDF(payload, String(filename)),
(err) => this.handleError(err)
);
}
}

View File

@ -1,55 +0,0 @@
import {
ExpressController,
type RendererFormat,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { ReportProformaByIdQueryRequestDTO } from "../../../../../common";
import type { ReportProformaUseCase } from "../../../../application/index.ts";
import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class ReportProformaController extends ExpressController {
public constructor(private readonly useCase: ReportProformaUseCase) {
super();
this.errorMapper = proformasApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
protected async executeImpl() {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { proforma_id } = this.req.params;
const { format } = this.req.query as ReportProformaByIdQueryRequestDTO;
const result = await this.useCase.execute({
proforma_id,
companyId,
format: format as RendererFormat,
});
return result.match(
({ payload, filename }) => {
if (format === "PDF") {
return this.downloadPDF(payload as Buffer<ArrayBuffer>, String(filename));
}
if (format === "HTML") {
return this.downloadHTML(payload as unknown as string);
}
// JSON
return this.json(payload);
},
(err) => this.handleError(err)
);
}
}

View File

@ -5,6 +5,7 @@ import {
ValidationApiError, ValidationApiError,
} from "@erp/core/api"; } from "@erp/core/api";
import { CompanyReportProfileNotFoundError, isCompanyReportProfileNotFoundError } from "../../../application";
import { import {
type CustomerInvoiceIdAlreadyExistsError, type CustomerInvoiceIdAlreadyExistsError,
type EntityIsNotProformaError, type EntityIsNotProformaError,
@ -77,8 +78,19 @@ const proformaCannotBeDeletedRule: ErrorToApiRule = {
), ),
}; };
const companyReportProfileNotFoundRule: ErrorToApiRule = {
priority: 130,
matches: (e) => isCompanyReportProfileNotFoundError(e),
build: (e) =>
new ConflictApiError(
(e as CompanyReportProfileNotFoundError).message ||
"Cannot generate the proforma report because the company document profile is missing."
),
};
// Cómo aplicarla: crea una nueva instancia del mapper con la regla extra // Cómo aplicarla: crea una nueva instancia del mapper con la regla extra
export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(companyReportProfileNotFoundRule)
.register(proformaDuplicateRule) .register(proformaDuplicateRule)
.register(proformaItemMismatchError) .register(proformaItemMismatchError)
.register(entityIsNotProformaError) .register(entityIsNotProformaError)

View File

@ -6,8 +6,9 @@ import {
GetProformaController, GetProformaController,
IssueProformaController, IssueProformaController,
ListProformasController, ListProformasController,
PreviewProformaReportController,
type ProformasInternalDeps, type ProformasInternalDeps,
ReportProformaController, ReportProformaPdfController,
} from ".."; } from "..";
import { import {
@ -17,8 +18,8 @@ import {
GetProformaByIdRequestSchema, GetProformaByIdRequestSchema,
IssueProformaByIdParamsRequestSchema, IssueProformaByIdParamsRequestSchema,
ListProformasRequestSchema, ListProformasRequestSchema,
ReportProformaByIdParamsRequestSchema, PreviewProformaReportByIdParamsRequestSchema,
ReportProformaByIdQueryRequestSchema, ReportProformaPdfByIdParamsRequestSchema,
UpdateProformaByIdParamsRequestSchema, UpdateProformaByIdParamsRequestSchema,
UpdateProformaByIdRequestSchema, UpdateProformaByIdRequestSchema,
} from "../../../../common"; } from "../../../../common";
@ -70,14 +71,21 @@ export const proformasRouter = (params: StartParams) => {
); );
router.get( router.get(
"/:proforma_id/report", "/:proforma_id/report/pdf",
//checkTabContext, validateRequest(ReportProformaPdfByIdParamsRequestSchema, "params"),
validateRequest(ReportProformaByIdParamsRequestSchema, "params"),
validateRequest(ReportProformaByIdQueryRequestSchema, "query"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.reportProforma(); const useCase = deps.useCases.reportProformaPdf();
const controller = new ReportProformaController(useCase); const controller = new ReportProformaPdfController(useCase);
return controller.execute(req, res, next);
}
);
router.get(
"/:proforma_id/report/preview",
validateRequest(PreviewProformaReportByIdParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.previewProformaReport();
const controller = new PreviewProformaReportController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
); );

View File

@ -4,5 +4,6 @@ export * from "./delete-proforma-by-id.request.dto";
export * from "./get-proforma-by-id.request.dto"; export * from "./get-proforma-by-id.request.dto";
export * from "./issue-proforma-by-id.request.dto"; export * from "./issue-proforma-by-id.request.dto";
export * from "./list-proformas.request.dto"; export * from "./list-proformas.request.dto";
export * from "./report-proforma-by-id.request.dto"; export * from "./preview-proforma-report-by-id.request.dto";
export * from "./report-proforma-pdf-by-id.request.dto";
export * from "./update-proforma-by-id.request.dto"; export * from "./update-proforma-by-id.request.dto";

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const PreviewProformaReportByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export type PreviewProformaReportByIdParamsRequestDTO = z.infer<
typeof PreviewProformaReportByIdParamsRequestSchema
>;

View File

@ -1,21 +0,0 @@
import { z } from "zod/v4";
export const ReportProformaByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export type ReportProformaByIdParamsRequestDTO = z.infer<
typeof ReportProformaByIdParamsRequestSchema
>;
export const ReportProformaByIdQueryRequestSchema = z.object({
format: z
.string()
.default("pdf")
.transform((v) => v.trim().toLowerCase())
.pipe(z.enum(["pdf", "html", "json"]))
.transform((v) => v.toUpperCase() as "PDF" | "HTML" | "JSON"),
});
export type ReportProformaByIdQueryRequestDTO = z.infer<
typeof ReportProformaByIdQueryRequestSchema
>;

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const ReportProformaPdfByIdParamsRequestSchema = z.object({
proforma_id: z.string(),
});
export type ReportProformaPdfByIdParamsRequestDTO = z.infer<
typeof ReportProformaPdfByIdParamsRequestSchema
>;

View File

@ -2,5 +2,4 @@ export * from "./create-proforma.response.dto";
export * from "./get-proforma-by-id.response.dto"; export * from "./get-proforma-by-id.response.dto";
export * from "./issue-proforma-by-id.response.dto"; export * from "./issue-proforma-by-id.response.dto";
export * from "./list-proformas.response.dto"; export * from "./list-proformas.response.dto";
export * from "./report-proforma-by-id.response.dto";
export * from "./update-proformas-by-id.response.dto"; export * from "./update-proformas-by-id.response.dto";

View File

@ -1 +0,0 @@
export type ReportProformaByIdResponseDTO = Blob;

View File

@ -2,7 +2,9 @@ import { DataTableColumnHeader } from "@repo/rdx-ui/components";
import { DateHelper } from "@repo/rdx-utils"; import { DateHelper } from "@repo/rdx-utils";
import { import {
Button, Button,
ButtonGroup,
Checkbox, Checkbox,
Spinner,
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
@ -11,6 +13,7 @@ import {
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { import {
ExternalLinkIcon, ExternalLinkIcon,
FileDownIcon,
FileTextIcon, FileTextIcon,
PencilIcon, PencilIcon,
RefreshCwIcon, RefreshCwIcon,
@ -39,6 +42,10 @@ type GridActionHandlers = {
canIssue?: (proforma: ProformaListRow) => boolean; canIssue?: (proforma: ProformaListRow) => boolean;
canEdit?: (proforma: ProformaListRow) => boolean; canEdit?: (proforma: ProformaListRow) => boolean;
canDelete?: (proforma: ProformaListRow) => boolean; canDelete?: (proforma: ProformaListRow) => boolean;
onDownloadPdf?: (proforma: ProformaListRow) => void;
pdfDownloadingId?: string | null;
isPdfDownloading?: boolean;
}; };
export function useProformasGridColumns( export function useProformasGridColumns(
@ -269,8 +276,12 @@ export function useProformasGridColumns(
const availableTransitions = const availableTransitions =
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? []; PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
const isPDFLoading =
actionHandlers.isPdfDownloading && actionHandlers.pdfDownloadingId === proforma.id;
const stop = (e: React.MouseEvent | React.KeyboardEvent) => e.stopPropagation();
return ( return (
<div className="flex items-center gap-1 justify-end"> <ButtonGroup>
{!isIssued && actionHandlers.onEditClick && ( {!isIssued && actionHandlers.onEditClick && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@ -352,6 +363,34 @@ export function useProformasGridColumns(
</TooltipProvider> </TooltipProvider>
)} )}
{/* Descargar en PDF */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className={"size-8"}
disabled={isPDFLoading}
onClick={(e) => {
stop(e);
actionHandlers.onDownloadPdf?.(proforma);
}}
size="icon"
variant="ghost"
>
{isPDFLoading ? (
<Spinner className="size-4 cursor-progress" />
) : (
<FileDownIcon className="size-4 cursor-pointer" />
)}
<span className="sr-only">Descargar PDF</span>
</Button>
}
/>
<TooltipContent>Descargar PDF</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Eliminar */} {/* Eliminar */}
{!isIssued && actionHandlers.onDeleteClick && ( {!isIssued && actionHandlers.onDeleteClick && (
<TooltipProvider> <TooltipProvider>
@ -377,7 +416,7 @@ export function useProformasGridColumns(
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
)} )}
</div> </ButtonGroup>
); );
}, },
}, },

View File

@ -64,6 +64,8 @@ export const ListProformasPage = () => {
deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)), deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)),
onChangeStatusClick: (proforma) => onChangeStatusClick: (proforma) =>
changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(proforma)), changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(proforma)),
onDownloadPdf: (proforma) => null,
}); });
const isPanelOpen = panelCtrl.panelState.isOpen; const isPanelOpen = panelCtrl.panelState.isOpen;

View File

@ -1,35 +1,22 @@
import type { IDataSource } from "@erp/core/client"; import type { IDataSource } from "@erp/core/client";
import type {
ReportProformaByIdQueryRequestDTO,
ReportProformaByIdResponseDTO,
} from "../../../../common";
export type ProformaReportFormat = "PDF" | "HTML" | "JSON";
export interface ReportProformaByIdParams { export interface ReportProformaByIdParams {
id: string; id: string;
data: ReportProformaByIdQueryRequestDTO;
} }
export type ReportProformaByIdResult = ReportProformaByIdResponseDTO; export type ReportProformaByIdResult = Blob;
export const reportProformaById = ( export const reportProformaById = (
dataSource: IDataSource, dataSource: IDataSource,
params: ReportProformaByIdParams params: ReportProformaByIdParams
): Promise<ReportProformaByIdResult> => { ): Promise<ReportProformaByIdResult> => {
const { const { id } = params;
id,
data: { format = "PDF" },
} = params;
if (!id) throw new Error("proformaId is required"); if (!id) throw new Error("proformaId is required");
return dataSource.custom<ReportProformaByIdQueryRequestDTO, ReportProformaByIdResponseDTO>({ return dataSource.custom<undefined, Blob>({
path: `proformas/${id}/report`, path: `proformas/${id}/report/pdf`,
method: "get", method: "get",
data: { data: undefined,
format,
},
}); });
}; };