Informes con FastReport
This commit is contained in:
parent
5ce3b3fa53
commit
1a33dbac0c
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@ -1,10 +1,12 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"mfeckies.handlebars-formatter",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"biomejs.biome",
|
||||
"cweijan.vscode-mysql-client2",
|
||||
"ms-vscode.vscode-json",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"cweijan.dbclient-jdbc"
|
||||
"cweijan.dbclient-jdbc",
|
||||
"nabous.handlebars-preview-plus"
|
||||
]
|
||||
}
|
||||
|
||||
@ -23,4 +23,10 @@ JWT_REFRESH_EXPIRATION=7d
|
||||
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome
|
||||
|
||||
TEMPLATES_PATH=/home/rodax/Documentos/uecko-erp/modules
|
||||
TEMPLATES_PATH=/home/rodax/Documentos/uecko-erp/modules
|
||||
DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/documents
|
||||
|
||||
SIGN_DOCUMENTS=false
|
||||
SIGNED_DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/signed-documents
|
||||
|
||||
FASTREPORT_BIN=/home/rodax/Documentos/uecko-erp/tools/fastreport-cli/publish/linux/FastReportCliGenerator
|
||||
@ -32,6 +32,14 @@ API_IMAGE=factuges-server:rodax-latest
|
||||
# Plantillas
|
||||
TEMPLATES_PATH=/repo/apps/server/templates
|
||||
|
||||
# Documentos generados
|
||||
DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/documents
|
||||
|
||||
# Firma de documentos
|
||||
SIGN_DOCUMENTS=false
|
||||
SIGNED_DOCUMENTS_PATH=/home/rodax/Documentos/uecko-erp/out/rodax/signed-documents
|
||||
|
||||
|
||||
# Chrome executable path (Puppeteer)
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Request } from "express";
|
||||
import { AuthenticatedUser, TabContext } from "../../domain";
|
||||
import type { Request } from "express";
|
||||
|
||||
import type { AuthenticatedUser, TabContext } from "../../domain";
|
||||
|
||||
export interface TabContextRequest extends Request {
|
||||
tabContext?: TabContext;
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
//import { authProvider } from "@/contexts/auth/infraestructure";
|
||||
import type { NextFunction, Response } from "express";
|
||||
|
||||
import { UniqueID } from "@/core/common/domain";
|
||||
import { ApiError, ExpressController } from "@/core/common/presentation";
|
||||
//import { authProvider } from "@/contexts/auth/infraestructure";
|
||||
import { NextFunction, Response } from "express";
|
||||
|
||||
import { authProvider } from "../../../../../../../modules/auth/src/api/lib/passport";
|
||||
import { AuthenticatedUser } from "../../domain";
|
||||
import { AuthenticatedRequest } from "../express/types";
|
||||
import type { AuthenticatedUser } from "../../domain";
|
||||
import type { AuthenticatedRequest } from "../express/types";
|
||||
|
||||
// Comprueba el rol del usuario
|
||||
const _authorizeUser = (condition: (user: AuthenticatedUser) => boolean) => {
|
||||
return (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
const user = req.user as AuthenticatedUser;
|
||||
|
||||
if (!user || !condition(user)) {
|
||||
if (!(user && condition(user))) {
|
||||
return ExpressController.errorResponse(
|
||||
new ApiError({
|
||||
status: 401,
|
||||
|
||||
@ -39,9 +39,16 @@ const DB_SYNC_MODE =
|
||||
// Opcional: timezone para Sequelize (según necesidades)
|
||||
const APP_TIMEZONE = process.env.APP_TIMEZONE ?? "Europe/Madrid";
|
||||
|
||||
// FASTREPORT_BIN: ruta al ejecutable de FastReport CLI
|
||||
// Si no se define, se buscará en rutas estándar según SO.
|
||||
const FASTREPORT_BIN = process.env.FASTREPORT_BIN;
|
||||
|
||||
// Ruta raíz para plantillas (templates)
|
||||
const TEMPLATES_PATH = process.env.TEMPLATES_PATH ?? "./templates";
|
||||
|
||||
// Documentos
|
||||
const DOCUMENTS_PATH = process.env.DOCUMENTS_PATH ?? "./documents";
|
||||
|
||||
// Proxy (no usáis ahora, pero dejamos la variable por si se activa en el futuro)
|
||||
const TRUST_PROXY = asNumber(process.env.TRUST_PROXY, 0);
|
||||
|
||||
@ -61,6 +68,8 @@ export const ENV = {
|
||||
APP_TIMEZONE,
|
||||
TRUST_PROXY,
|
||||
TEMPLATES_PATH,
|
||||
DOCUMENTS_PATH,
|
||||
FASTREPORT_BIN,
|
||||
} as const;
|
||||
|
||||
export const Flags = {
|
||||
|
||||
@ -222,6 +222,7 @@ process.on("uncaughtException", async (error: Error) => {
|
||||
logger.info(`DB_LOGGING: ${ENV.DB_LOGGING}`);
|
||||
logger.info(`DB_SYNC_MODE: ${ENV.DB_SYNC_MODE}`);
|
||||
|
||||
logger.info(`FASTREPORT_BIN: ${ENV.FASTREPORT_BIN}`);
|
||||
logger.info(`TEMPLATES_PATH: ${ENV.TEMPLATES_PATH}`);
|
||||
|
||||
const database = await tryConnectToDatabase();
|
||||
@ -234,11 +235,11 @@ process.on("uncaughtException", async (error: Error) => {
|
||||
registerHealthRoutes(app, API_BASE_PATH, () => ({ ready: isReady }));
|
||||
|
||||
await initModules({
|
||||
env: ENV,
|
||||
app,
|
||||
database,
|
||||
baseRoutePath: API_BASE_PATH,
|
||||
logger,
|
||||
templateRootPath: ENV.TEMPLATES_PATH,
|
||||
});
|
||||
|
||||
// El servidor ya está listo para recibir tráfico
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { ModuleParams } from "@erp/core/api";
|
||||
import type { ModuleParams } from "@erp/core/api";
|
||||
|
||||
import { ENV } from "../../config";
|
||||
import { logger } from "../logger";
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from "./auth-types";
|
||||
export * from "./mock-user.middleware";
|
||||
export * from "./tenancy.middleware";
|
||||
export * from "./user.middleware";
|
||||
export * from "./require-authenticated.middleware";
|
||||
export * from "./require-company-context.middleware";
|
||||
|
||||
@ -7,7 +7,7 @@ import type { RequestWithAuth } from "./auth-types";
|
||||
* Middleware que exige presencia de usuario (sin validar companyId).
|
||||
* Debe ir DESPUÉS del middleware de autenticación.
|
||||
*/
|
||||
export function enforceUser() {
|
||||
export function requireAuthenticated() {
|
||||
return (req: RequestWithAuth, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
|
||||
@ -1,4 +1,4 @@
|
||||
import { ExpressController, UnauthorizedApiError } from "@erp/core/api";
|
||||
import { ExpressController, ForbiddenApiError } from "@erp/core/api";
|
||||
import type { NextFunction, Response } from "express";
|
||||
|
||||
import type { RequestWithAuth } from "./auth-types";
|
||||
@ -7,11 +7,15 @@ import type { RequestWithAuth } from "./auth-types";
|
||||
* Middleware que exige presencia de usuario y companyId.
|
||||
* Debe ir DESPUÉS del middleware de autenticación.
|
||||
*/
|
||||
export function enforceTenant() {
|
||||
export function requireCompanyContext() {
|
||||
return (req: RequestWithAuth, res: Response, next: NextFunction) => {
|
||||
// Validación básica del tenant
|
||||
if (!req.user?.companyId) {
|
||||
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
|
||||
return ExpressController.errorResponse(
|
||||
new ForbiddenApiError("Company context required"),
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
next();
|
||||
};
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./errors";
|
||||
export * from "./presenters";
|
||||
export * from "./renderers";
|
||||
|
||||
@ -2,4 +2,3 @@ export * from "./presenter";
|
||||
export * from "./presenter.interface";
|
||||
export * from "./presenter-registry";
|
||||
export * from "./presenter-registry.interface";
|
||||
export * from "./template-presenter";
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { DTO } from "@erp/core/common";
|
||||
|
||||
import type { IPresenter } from "./presenter.interface";
|
||||
|
||||
/**
|
||||
@ -6,53 +8,29 @@ import type { IPresenter } from "./presenter.interface";
|
||||
export type PresenterKey = {
|
||||
resource: string;
|
||||
projection: "FULL" | "LIST" | "REPORT" | (string & {});
|
||||
format?: "JSON" | "HTML" | "PDF" | "CSV" | (string & {});
|
||||
format?: "DTO";
|
||||
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<any, any>).toOutput(invoice);
|
||||
* console.log("PDF generado:", output);
|
||||
* });
|
||||
*
|
||||
**/
|
||||
export type PresenterFormat = NonNullable<PresenterKey["format"]>;
|
||||
|
||||
export type PresenterFormatOutputMap = {
|
||||
DTO: DTO;
|
||||
};
|
||||
|
||||
export interface IPresenterRegistry {
|
||||
/**
|
||||
* Obtiene un mapper de dominio por clave de proyección.
|
||||
*/
|
||||
getPresenter<TSource = unknown, TOutput = unknown>(
|
||||
key: PresenterKey
|
||||
): IPresenter<TSource, TOutput>;
|
||||
getPresenter<TSource, F extends PresenterFormat = "DTO">(
|
||||
key: Omit<PresenterKey, "format"> & { format?: F }
|
||||
): IPresenter<TSource, PresenterFormatOutputMap[F]>;
|
||||
|
||||
/**
|
||||
* Registra un mapper de dominio bajo una clave de proyección.
|
||||
*/
|
||||
registerPresenter<TSource = unknown, TOutput = unknown>(
|
||||
registerPresenter<TSource, TOutput>(
|
||||
key: PresenterKey,
|
||||
presenter: IPresenter<TSource, TOutput>
|
||||
): this;
|
||||
|
||||
@ -9,7 +9,7 @@ export class InMemoryPresenterRegistry implements IPresenterRegistry {
|
||||
private _normalizeKey(key: PresenterKey): PresenterKey {
|
||||
return {
|
||||
...key,
|
||||
format: key.format ?? "JSON", // 👈 valor por defecto
|
||||
format: key.format ?? "DTO", // 👈 valor por defecto
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
export type DTO<T = unknown> = T;
|
||||
export type BinaryOutput = Buffer; // Puedes ampliar a Readable si usas streams
|
||||
import type { DTO } from "../../../common/types";
|
||||
|
||||
export type IPresenterOutputParams = Record<string, unknown>;
|
||||
|
||||
export interface IPresenter<TSource = unknown, TOutput = DTO> {
|
||||
export interface IPresenter<TSource, TOutput = DTO> {
|
||||
toOutput(source: TSource, params?: IPresenterOutputParams): TOutput | Promise<TOutput>;
|
||||
}
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
import type { HandlebarsTemplateResolver } from "../../infrastructure";
|
||||
|
||||
import { Presenter } from "./presenter";
|
||||
import type { IPresenter } from "./presenter.interface";
|
||||
import type { IPresenterRegistry } from "./presenter-registry.interface";
|
||||
|
||||
export abstract class TemplatePresenter<TSource = unknown, TOutput = unknown>
|
||||
extends Presenter<TSource, TOutput>
|
||||
implements IPresenter<TSource, TOutput>
|
||||
{
|
||||
constructor(
|
||||
protected presenterRegistry: IPresenterRegistry,
|
||||
protected templateResolver: HandlebarsTemplateResolver
|
||||
) {
|
||||
super(presenterRegistry);
|
||||
}
|
||||
}
|
||||
5
modules/core/src/api/application/renderers/index.ts
Normal file
5
modules/core/src/api/application/renderers/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from "./renderer";
|
||||
export * from "./renderer.interface";
|
||||
export * from "./renderer-registry";
|
||||
export * from "./renderer-registry.interface";
|
||||
export * from "./renderer-template-resolver.interface";
|
||||
@ -0,0 +1,36 @@
|
||||
import type { IRenderer } from "./renderer.interface";
|
||||
|
||||
export type RendererFormat = "HTML" | "PDF";
|
||||
|
||||
/**
|
||||
* 🔑 Claves de proyección comunes para seleccionar Renderers
|
||||
*/
|
||||
export type RendererKey = {
|
||||
resource: string;
|
||||
format?: RendererFormat;
|
||||
version?: number; // 1 | 2
|
||||
locale?: string; // es | en | fr
|
||||
};
|
||||
|
||||
export type RendererFormatOutputMap = {
|
||||
HTML: string;
|
||||
PDF: Buffer<ArrayBuffer>;
|
||||
};
|
||||
|
||||
export interface IRendererRegistry {
|
||||
/**
|
||||
* Obtiene un mapper de dominio por clave de proyección.
|
||||
*/
|
||||
getRenderer<TSource, F extends RendererFormat = "PDF">(
|
||||
key: RendererKey
|
||||
): IRenderer<TSource, RendererFormatOutputMap[F]>;
|
||||
|
||||
/**
|
||||
* Registra un mapper de dominio bajo una clave de proyección.
|
||||
*/
|
||||
registerRenderer<TSource, TOutput>(key: RendererKey, renderer: IRenderer<TSource, TOutput>): this;
|
||||
|
||||
registerRenderers(
|
||||
renderers: Array<{ key: RendererKey; renderer: IRenderer<unknown, unknown> }>
|
||||
): this;
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
import { ApplicationError } from "../errors";
|
||||
|
||||
import type { IRenderer } from "./renderer.interface";
|
||||
import type { IRendererRegistry, RendererKey } from "./renderer-registry.interface";
|
||||
|
||||
export class InMemoryRendererRegistry implements IRendererRegistry {
|
||||
private registry: Map<string, IRenderer<any, any>> = new Map();
|
||||
|
||||
private _normalizeKey(key: RendererKey): RendererKey {
|
||||
return {
|
||||
...key,
|
||||
format: key.format ?? "PDF", // 👈 valor por defecto
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔹 Construye la clave única para el registro.
|
||||
*/
|
||||
private _buildKey(key: RendererKey): string {
|
||||
const { resource, format, version, locale } = this._normalizeKey(key);
|
||||
return [
|
||||
resource.toLowerCase(),
|
||||
format!.toLowerCase(),
|
||||
version ?? "latest",
|
||||
locale ?? "default",
|
||||
].join("::");
|
||||
}
|
||||
|
||||
private _registerRenderer<TSource, TOutput>(
|
||||
key: RendererKey,
|
||||
renderer: IRenderer<TSource, TOutput>
|
||||
): void {
|
||||
const exactKey = this._buildKey(key);
|
||||
this.registry.set(exactKey, renderer);
|
||||
}
|
||||
|
||||
getRenderer<TSource, TOutput>(key: RendererKey): IRenderer<TSource, TOutput> {
|
||||
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(
|
||||
`[InMemoryRendererRegistry] Renderer not registered: ${key.resource}::${key.format}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApplicationError(
|
||||
`[InMemoryRendererRegistry] Renderer not registered: ${key.resource}::${key.format}`
|
||||
);
|
||||
}
|
||||
|
||||
registerRenderer<TSource, TOutput>(
|
||||
key: RendererKey,
|
||||
renderer: IRenderer<TSource, TOutput>
|
||||
): this {
|
||||
this._registerRenderer(key, renderer);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* ✅ Registro en lote de renderers.
|
||||
*/
|
||||
registerRenderers(renderers: Array<{ key: RendererKey; renderer: IRenderer<any, any> }>): this {
|
||||
for (const { key, renderer } of renderers) {
|
||||
this._registerRenderer(key, renderer);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export interface IRendererTemplateResolver {
|
||||
/** Devuelve la ruta absoluta del fichero de plantilla */
|
||||
resolveTemplatePath(module: string, companySlug: string, templateName: string): string;
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export type IRendererParams = Record<string, unknown>;
|
||||
|
||||
export interface IRenderer<TSource, TOutput> {
|
||||
render(source: TSource, params?: IRendererParams): TOutput | Promise<TOutput>;
|
||||
}
|
||||
7
modules/core/src/api/application/renderers/renderer.ts
Normal file
7
modules/core/src/api/application/renderers/renderer.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import type { IRenderer, IRendererParams } from "./renderer.interface";
|
||||
|
||||
export abstract class Renderer<TSource = unknown, TOutput = unknown>
|
||||
implements IRenderer<TSource, TOutput>
|
||||
{
|
||||
abstract render(source: TSource, params?: IRendererParams): TOutput | Promise<TOutput>;
|
||||
}
|
||||
@ -30,6 +30,10 @@ import {
|
||||
isInfrastructureRepositoryError,
|
||||
isInfrastructureUnavailableError,
|
||||
} from "../errors";
|
||||
import {
|
||||
type FastReportError,
|
||||
isFastReportError,
|
||||
} from "../renderers/fast-report/fastreport-errors";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
@ -159,6 +163,14 @@ const defaultRules: ReadonlyArray<ErrorToApiRule> = [
|
||||
),
|
||||
},
|
||||
|
||||
// 5.5) Errores de FastReport inesperados
|
||||
{
|
||||
priority: 55,
|
||||
matches: (e) => isFastReportError(e),
|
||||
build: (e) =>
|
||||
new InternalApiError((e as FastReportError).message, "Unexpected FastReport error"),
|
||||
},
|
||||
|
||||
// 6) Infra no transitoria (errores de repositorio inesperados)
|
||||
{
|
||||
priority: 50,
|
||||
|
||||
@ -135,6 +135,14 @@ export abstract class ExpressController {
|
||||
return this.res.send(htmlString);
|
||||
}
|
||||
|
||||
public json(data: any) {
|
||||
this.res.set({
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
});
|
||||
|
||||
return this.res.json(data);
|
||||
}
|
||||
|
||||
protected clientError(message: string, errors?: any[] | any) {
|
||||
return this.handleApiError(
|
||||
new ValidationApiError(message, Array.isArray(errors) ? errors : [errors])
|
||||
|
||||
@ -31,18 +31,20 @@ export function guardFail(error: ApiError): GuardResultLike {
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Guards reutilizables (auth/tenancy). Si prefieres, muévelos a src/lib/http/express-guards.ts
|
||||
|
||||
export function authGuard(): GuardFn {
|
||||
return ({ controller }: GuardContext) => {
|
||||
const user = controller.getUser();
|
||||
if (!user) return guardFail(new UnauthorizedApiError("Unauthorized"));
|
||||
export function requireAuthenticatedGuard(): GuardFn {
|
||||
return ({ controller }) => {
|
||||
if (!controller.getUser()) {
|
||||
return guardFail(new UnauthorizedApiError("Unauthorized"));
|
||||
}
|
||||
return guardOk();
|
||||
};
|
||||
}
|
||||
|
||||
export function tenantGuard(): GuardFn {
|
||||
return ({ controller }: GuardContext) => {
|
||||
const tenantId = controller.getTenantId();
|
||||
if (!tenantId) return guardFail(new ForbiddenApiError("Tenant not found for user"));
|
||||
export function requireCompanyContextGuard(): GuardFn {
|
||||
return ({ controller }) => {
|
||||
if (!controller.getTenantId()) {
|
||||
return guardFail(new ForbiddenApiError("Company context required"));
|
||||
}
|
||||
return guardOk();
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,3 +3,4 @@ export * from "./errors";
|
||||
export * from "./express-controller";
|
||||
export * from "./express-guards";
|
||||
export * from "./middlewares";
|
||||
export * from "./request-with-auth";
|
||||
|
||||
@ -0,0 +1,122 @@
|
||||
import type { EmailAddress, UniqueID } from "@repo/rdx-ddd";
|
||||
import type { NextFunction, Response } from "express";
|
||||
|
||||
import { ForbiddenApiError, UnauthorizedApiError } from "../errors";
|
||||
import { ExpressController } from "../express-controller";
|
||||
import type { RequestUser, RequestWithAuth } from "../request-with-auth";
|
||||
|
||||
export type AccessTokenPayload = {
|
||||
sub: string;
|
||||
email?: string;
|
||||
roles?: string[];
|
||||
sid?: string;
|
||||
};
|
||||
|
||||
export interface AccessTokenVerifier {
|
||||
verifyAccessToken(token: string): Promise<AccessTokenPayload>;
|
||||
}
|
||||
|
||||
export type AuthenticateRequestOptions = {
|
||||
tokenVerifier: AccessTokenVerifier;
|
||||
envCompanyId: string;
|
||||
|
||||
companyHeaderName?: string;
|
||||
authorizationHeaderName?: string;
|
||||
|
||||
defaultRoles?: string[];
|
||||
|
||||
/**
|
||||
* Entorno actual. Si es "production", el cliente DEBE enviar X-Company-Id.
|
||||
* En otros entornos, se inyecta automáticamente si falta.
|
||||
*/
|
||||
environment?: "development" | "test" | "production";
|
||||
};
|
||||
|
||||
function getHeader(req: any, name: string): string | undefined {
|
||||
const value = typeof req.get === "function" ? req.get(name) : undefined;
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
|
||||
const raw = req.headers?.[name.toLowerCase()];
|
||||
if (typeof raw === "string" && raw.trim()) return raw.trim();
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseBearerToken(authorization?: string): string | undefined {
|
||||
if (!authorization) return undefined;
|
||||
const [scheme, token] = authorization.split(" ");
|
||||
if (scheme?.toLowerCase() !== "bearer") return undefined;
|
||||
return token?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function authenticateRequest(options: AuthenticateRequestOptions) {
|
||||
const companyHeaderName = options.companyHeaderName ?? "X-Company-Id";
|
||||
const authorizationHeaderName = options.authorizationHeaderName ?? "authorization";
|
||||
const defaultRoles = options.defaultRoles ?? ["ADMIN"];
|
||||
const environment = options.environment ?? (process.env.NODE_ENV as any) ?? "development";
|
||||
|
||||
if (!options.envCompanyId?.trim()) {
|
||||
throw new Error("ENV company id is missing (envCompanyId)");
|
||||
}
|
||||
|
||||
return async (req: RequestWithAuth, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// ─────────────────────────────────────────
|
||||
// 1) Autenticación (JWT)
|
||||
const authorization = getHeader(req, authorizationHeaderName);
|
||||
const token = parseBearerToken(authorization);
|
||||
|
||||
if (!token) {
|
||||
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
|
||||
}
|
||||
|
||||
const payload = await options.tokenVerifier.verifyAccessToken(token);
|
||||
if (!payload?.sub) {
|
||||
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 2) Company context
|
||||
const requestedCompanyId = getHeader(req, companyHeaderName);
|
||||
let resolvedCompanyId: string | undefined;
|
||||
|
||||
if (requestedCompanyId) {
|
||||
if (requestedCompanyId !== options.envCompanyId) {
|
||||
return ExpressController.errorResponse(
|
||||
new ForbiddenApiError("Invalid company context"),
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
resolvedCompanyId = requestedCompanyId;
|
||||
} else {
|
||||
// No viene en header
|
||||
if (environment === "production") {
|
||||
return ExpressController.errorResponse(
|
||||
new ForbiddenApiError("Company context required"),
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
// Dev/Test → inyección automática
|
||||
resolvedCompanyId = options.envCompanyId;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 3) Construir contexto de usuario (MVP)
|
||||
const user: RequestUser = {
|
||||
userId: payload.sub as unknown as UniqueID,
|
||||
companyId: resolvedCompanyId as unknown as UniqueID,
|
||||
companySlug: "default",
|
||||
roles: payload.roles?.length ? payload.roles : defaultRoles,
|
||||
email: payload.email as unknown as EmailAddress,
|
||||
};
|
||||
|
||||
(req as any).user = user;
|
||||
|
||||
next();
|
||||
} catch {
|
||||
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -3,8 +3,8 @@ import type { Request } from "express";
|
||||
|
||||
export type RequestUser = {
|
||||
userId: UniqueID;
|
||||
companyId: UniqueID;
|
||||
companySlug: string;
|
||||
companyId?: UniqueID;
|
||||
companySlug?: string;
|
||||
roles?: string[];
|
||||
email?: EmailAddress;
|
||||
};
|
||||
@ -3,5 +3,6 @@ export * from "./errors";
|
||||
export * from "./express";
|
||||
export * from "./logger";
|
||||
export * from "./mappers";
|
||||
export * from "./renderers";
|
||||
export * from "./reporting";
|
||||
export * from "./sequelize";
|
||||
export * from "./templates";
|
||||
|
||||
@ -0,0 +1,53 @@
|
||||
import { InfrastructureError } from "../../errors";
|
||||
|
||||
/**
|
||||
* Error base de FastReport.
|
||||
* NO debe cruzar el boundary de Infraestructura sin mapearse.
|
||||
*/
|
||||
export abstract class FastReportError extends InfrastructureError {}
|
||||
|
||||
export const isFastReportError = (e: unknown): e is FastReportError => e instanceof FastReportError;
|
||||
|
||||
/**
|
||||
* El ejecutable FastReport no existe o no es accesible.
|
||||
*/
|
||||
export class FastReportExecutableNotFoundError extends FastReportError {
|
||||
public readonly code = "FASTREPORT_EXE_NOT_FOUND_ERROR" as const;
|
||||
|
||||
public constructor(executable: string, searchedPaths?: string[]) {
|
||||
super(
|
||||
searchedPaths
|
||||
? `FastReport executable "${executable}" not found. Searched in: ${searchedPaths.join(
|
||||
", "
|
||||
)}`
|
||||
: `FastReport executable "${executable}" not found`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* La plantilla .frx no existe.
|
||||
*/
|
||||
export class FastReportTemplateNotFoundError extends FastReportError {
|
||||
public readonly code = "FASTREPORT_TEMPLATE_NOT_FOUND_ERROR" as const;
|
||||
|
||||
public constructor(templatePath: string) {
|
||||
super(`FastReport template not found at path: ${templatePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error al ejecutar el proceso FastReport.
|
||||
* Incluye stderr o información del exit code.
|
||||
*/
|
||||
export class FastReportExecutionError extends FastReportError {
|
||||
public readonly code = "FASTREPORT_EXECUTION_ERROR" as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error de IO relacionado con FastReport
|
||||
* (lectura/escritura de ficheros temporales).
|
||||
*/
|
||||
export class FastReportIOError extends FastReportError {
|
||||
public readonly code = "FASTREPORT_IO_ERROR" as const;
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { buildSafePath } from "@repo/rdx-utils";
|
||||
|
||||
import { FastReportExecutableNotFoundError } from "./fastreport-errors";
|
||||
|
||||
/**
|
||||
* Resuelve la ruta absoluta del ejecutable FastReport.
|
||||
*
|
||||
* Reglas:
|
||||
* 1. ENV FASTREPORT_BIN (si existe)
|
||||
* 2. Rutas estándar por SO
|
||||
* 3. Fail-fast si no existe
|
||||
*/
|
||||
export class FastReportExecutableResolver {
|
||||
public resolve(): string {
|
||||
const fromEnv = process.env.FASTREPORT_BIN;
|
||||
if (fromEnv) {
|
||||
this.assertExecutableExists(fromEnv);
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
const executableName = this.resolveExecutableName();
|
||||
const candidatePaths = this.getCandidatePaths();
|
||||
|
||||
for (const basePath of candidatePaths) {
|
||||
const fullPath = buildSafePath({ basePath, segments: [], filename: executableName });
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
throw new FastReportExecutableNotFoundError(executableName, candidatePaths);
|
||||
}
|
||||
|
||||
private resolveExecutableName(): string {
|
||||
return os.platform() === "win32" ? "FastReportCliGenerator.exe" : "FastReportCliGenerator";
|
||||
}
|
||||
|
||||
private getCandidatePaths(): string[] {
|
||||
return ["/usr/local/bin", "/usr/bin", path.resolve(process.cwd(), "bin")];
|
||||
}
|
||||
|
||||
private assertExecutableExists(executablePath: string): void {
|
||||
if (!fs.existsSync(executablePath)) {
|
||||
throw new FastReportExecutableNotFoundError(executablePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import { Result, buildSafePath } from "@repo/rdx-utils";
|
||||
|
||||
import { FastReportExecutionError } from "./fastreport-errors";
|
||||
|
||||
/**
|
||||
* Ejecuta el binario FastReport como proceso externo.
|
||||
*
|
||||
* - Escribe JSON de entrada
|
||||
* - Ejecuta el proceso
|
||||
* - Lee el output generado
|
||||
* - Devuelve Result
|
||||
*/
|
||||
|
||||
export type FastReportProcessRunnerArgs = {
|
||||
templatePath: string; // Path to FRX template (required)
|
||||
data: string; // JSON data as string
|
||||
format: "PDF" | "HTML";
|
||||
workdir: string; // Directorio de trabajo temporal
|
||||
};
|
||||
|
||||
export class FastReportProcessRunner {
|
||||
public async run(
|
||||
executablePath: string,
|
||||
executableArgs: FastReportProcessRunnerArgs
|
||||
): Promise<Result<Buffer | string, FastReportExecutionError>> {
|
||||
const { templatePath, data, format, workdir } = executableArgs;
|
||||
|
||||
// Guardar datos de entrada en JSON
|
||||
const dataPath = buildSafePath({ basePath: workdir, segments: [], filename: "data.json" });
|
||||
|
||||
// Path de output según formato y con
|
||||
const outputPath = buildSafePath({
|
||||
basePath: workdir,
|
||||
segments: [],
|
||||
filename: format === "PDF" ? "output.pdf" : "output.html",
|
||||
});
|
||||
|
||||
await fs.writeFile(dataPath, data, "utf-8");
|
||||
|
||||
const args = this.buildArgs({
|
||||
templatePath,
|
||||
dataPath,
|
||||
outputPath,
|
||||
format,
|
||||
});
|
||||
|
||||
return this.executeProcess(executablePath, args, outputPath, executableArgs.format);
|
||||
}
|
||||
|
||||
private buildArgs(params: {
|
||||
templatePath: string;
|
||||
dataPath: string;
|
||||
outputPath: string;
|
||||
format: "PDF" | "HTML";
|
||||
}): string[] {
|
||||
return [
|
||||
`--template=${params.templatePath}`,
|
||||
`--data=${params.dataPath}`,
|
||||
`--output=${params.outputPath}`,
|
||||
`--format=${params.format}`,
|
||||
];
|
||||
}
|
||||
|
||||
private executeProcess(
|
||||
executablePath: string,
|
||||
args: string[],
|
||||
outputPath: string,
|
||||
format: "PDF" | "HTML"
|
||||
): Promise<Result<Buffer | string, FastReportExecutionError>> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(executablePath, args, {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
resolve(Result.fail(new FastReportExecutionError(String(error))));
|
||||
});
|
||||
|
||||
child.on("close", async (code) => {
|
||||
if (code !== 0) {
|
||||
return resolve(
|
||||
Result.fail(
|
||||
new FastReportExecutionError(stderr || `FastReport exited with code ${code}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const output =
|
||||
format === "PDF"
|
||||
? await fs.readFile(outputPath)
|
||||
: await fs.readFile(outputPath, "utf-8");
|
||||
|
||||
resolve(Result.ok(output));
|
||||
} catch (readError) {
|
||||
resolve(
|
||||
Result.fail(
|
||||
new FastReportExecutionError("Failed to read FastReport output", readError as Error)
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import type { ReportStorageKey } from "../../reporting";
|
||||
|
||||
export type FastReportRenderOptions = {
|
||||
inputData: unknown;
|
||||
format: "PDF" | "HTML";
|
||||
storageKey: ReportStorageKey;
|
||||
};
|
||||
@ -0,0 +1,140 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Renderer } from "../../../application";
|
||||
import type { ReportStorage, ReportStorageKey } from "../../reporting";
|
||||
|
||||
import {
|
||||
type FastReportError,
|
||||
FastReportExecutionError,
|
||||
FastReportTemplateNotFoundError,
|
||||
} from "./fastreport-errors";
|
||||
import type { FastReportExecutableResolver } from "./fastreport-executable-resolver";
|
||||
import type { FastReportProcessRunner } from "./fastreport-process-runner";
|
||||
import type { FastReportRenderOptions } from "./fastreport-render-options.type";
|
||||
import type { FastReportTemplateResolver } from "./fastreport-template-resolver";
|
||||
|
||||
export type FastReportRenderOutput = Result<
|
||||
{ payload: Buffer | string; templateChecksum: string },
|
||||
FastReportError
|
||||
>;
|
||||
|
||||
/**
|
||||
* Clase base para renderers FastReport.
|
||||
*/
|
||||
export abstract class FastReportRenderer extends Renderer<unknown, FastReportRenderOutput> {
|
||||
constructor(
|
||||
protected readonly executableResolver: FastReportExecutableResolver,
|
||||
protected readonly processRunner: FastReportProcessRunner,
|
||||
protected readonly templateResolver: FastReportTemplateResolver,
|
||||
protected readonly reportStorage: ReportStorage
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Punto de entrada común para renderizado.
|
||||
*/
|
||||
protected async renderInternal(
|
||||
options: FastReportRenderOptions
|
||||
): Promise<FastReportRenderOutput> {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
// Cache (read-through)
|
||||
const cached = await this.tryReadFromCache(options.storageKey);
|
||||
if (cached.isSome()) {
|
||||
return Result.ok({
|
||||
payload: cached.unwrap(),
|
||||
templateChecksum: "CACHED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolver plantilla
|
||||
const templatePath = this.resolveTemplatePath();
|
||||
console.log("Using FastReport template:", templatePath);
|
||||
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
return Result.fail(new FastReportTemplateNotFoundError(templatePath));
|
||||
}
|
||||
|
||||
const templateChecksum = this.calculateChecksum(templatePath);
|
||||
|
||||
// Llamar a FastReport
|
||||
const callResult = await this.callFastReportGenerator(options, templatePath);
|
||||
|
||||
if (callResult.isFailure) {
|
||||
return Result.fail(callResult.error);
|
||||
}
|
||||
|
||||
// Guardar documento generado (best-effort)
|
||||
await this.storageReport(options.storageKey, callResult.data);
|
||||
|
||||
return Result.ok({
|
||||
payload: callResult.data,
|
||||
templateChecksum,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("FastReport rendering error:", error);
|
||||
return Result.fail(
|
||||
new FastReportExecutionError("Unexpected FastReport rendering error", error as Error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async tryReadFromCache(docKey: ReportStorageKey): Promise<Maybe<Buffer | string>> {
|
||||
if (await this.reportStorage.exists(docKey)) {
|
||||
const cached = await this.reportStorage.read(docKey);
|
||||
return Maybe.some(cached);
|
||||
}
|
||||
return Maybe.none();
|
||||
}
|
||||
|
||||
protected async callFastReportGenerator(
|
||||
options: FastReportRenderOptions,
|
||||
templatePath: string
|
||||
): Promise<Result<Buffer | string, FastReportError>> {
|
||||
const executablePath = this.executableResolver.resolve();
|
||||
const workdir = this.resolveWorkdir();
|
||||
|
||||
const runResult = await this.processRunner.run(executablePath, {
|
||||
templatePath,
|
||||
data: JSON.stringify(options.inputData),
|
||||
format: options.format,
|
||||
workdir,
|
||||
});
|
||||
|
||||
if (runResult.isFailure) {
|
||||
return Result.fail(new FastReportExecutionError(runResult.error.message, runResult.error));
|
||||
}
|
||||
|
||||
return Result.ok(runResult.data);
|
||||
}
|
||||
|
||||
protected async storageReport(key: ReportStorageKey, payload: Buffer | string): Promise<void> {
|
||||
try {
|
||||
await this.reportStorage.write(key, payload);
|
||||
} catch (error) {
|
||||
// ⚠️ Importante: no romper generación por fallo de cache
|
||||
}
|
||||
}
|
||||
|
||||
protected resolveWorkdir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "fastreport-"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cada renderer concreto decide
|
||||
* qué plantilla usar.
|
||||
*/
|
||||
protected abstract resolveTemplatePath(): string;
|
||||
|
||||
protected calculateChecksum(filePath: string): string {
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
return crypto.createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import { RendererTemplateResolver } from "../renderer-template-resolver";
|
||||
|
||||
export class FastReportTemplateResolver extends RendererTemplateResolver {}
|
||||
@ -0,0 +1,6 @@
|
||||
export * from "./fastreport-errors";
|
||||
export * from "./fastreport-executable-resolver";
|
||||
export * from "./fastreport-process-runner";
|
||||
export * from "./fastreport-render-options.type";
|
||||
export * from "./fastreport-renderer.base";
|
||||
export * from "./fastreport-template-resolver";
|
||||
@ -3,9 +3,9 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import Handlebars from "handlebars";
|
||||
import { lookup } from "mime-types";
|
||||
|
||||
import { TemplateResolver } from "./template-resolver";
|
||||
import { RendererTemplateResolver } from "../renderer-template-resolver";
|
||||
|
||||
export class HandlebarsTemplateResolver extends TemplateResolver {
|
||||
export class HandlebarsTemplateResolver extends RendererTemplateResolver {
|
||||
protected readonly hbs = Handlebars.create();
|
||||
protected registered = false;
|
||||
protected readonly assetCache = new Map<string, string>();
|
||||
@ -1,2 +1 @@
|
||||
export * from "./handlebars-template-resolver";
|
||||
export * from "./template-resolver";
|
||||
3
modules/core/src/api/infrastructure/renderers/index.ts
Normal file
3
modules/core/src/api/infrastructure/renderers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./fast-report";
|
||||
export * from "./handlebars";
|
||||
export * from "./renderer-template-resolver";
|
||||
@ -1,21 +1,12 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface ITemplateResolver {
|
||||
/** Devuelve la ruta absoluta del fichero de plantilla */
|
||||
resolveTemplatePath(module: string, companySlug: string, templateName: string): string;
|
||||
|
||||
/** Compila el contenido de la plantilla (string) */
|
||||
compile(templateSource: string): unknown;
|
||||
|
||||
/** Localiza y compila el template */
|
||||
compileTemplate(module: string, companySlug: string, templateName: string): unknown;
|
||||
}
|
||||
import type { IRendererTemplateResolver } from "../../application";
|
||||
|
||||
/**
|
||||
* Resuelve rutas de plantillas para desarrollo y producción.
|
||||
*/
|
||||
export abstract class TemplateResolver implements ITemplateResolver {
|
||||
export abstract class RendererTemplateResolver implements IRendererTemplateResolver {
|
||||
constructor(protected readonly rootPath: string) {}
|
||||
|
||||
/** Une partes de ruta relativas al rootPath */
|
||||
@ -65,8 +56,4 @@ export abstract class TemplateResolver implements ITemplateResolver {
|
||||
protected readTemplateFile(templatePath: string): string {
|
||||
return readFileSync(templatePath, "utf8");
|
||||
}
|
||||
|
||||
abstract compile(templateSource: string): unknown;
|
||||
|
||||
abstract compileTemplate(module: string, companySlug: string, templateName: string): unknown;
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { buildSafePath } from "@repo/rdx-utils";
|
||||
|
||||
import type { ReportStorage, ReportStorageKey } from "./report-storage";
|
||||
|
||||
export class FileSystemReportStorage implements ReportStorage {
|
||||
public constructor(private readonly basePath: string) {}
|
||||
|
||||
public async exists(docKey: ReportStorageKey): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.resolvePath(docKey));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async read(docKey: ReportStorageKey): Promise<Buffer | string> {
|
||||
const filePath = this.resolvePath(docKey);
|
||||
return docKey.format === "PDF" ? fs.readFile(filePath) : fs.readFile(filePath, "utf-8");
|
||||
}
|
||||
|
||||
public async write(docKey: ReportStorageKey, payload: Buffer | string): Promise<void> {
|
||||
const filePath = this.resolvePath(docKey);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, payload);
|
||||
}
|
||||
|
||||
private resolvePath(key: ReportStorageKey): string {
|
||||
const ext = key.format.toLowerCase();
|
||||
|
||||
return buildSafePath({
|
||||
basePath: this.basePath,
|
||||
segments: [key.documentType.toLowerCase()],
|
||||
filename: `${key.documentId}.${ext}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
2
modules/core/src/api/infrastructure/reporting/index.ts
Normal file
2
modules/core/src/api/infrastructure/reporting/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./filesystem-report-storage";
|
||||
export * from "./report-storage";
|
||||
@ -0,0 +1,11 @@
|
||||
export type ReportStorageKey = {
|
||||
documentType: string;
|
||||
documentId: string;
|
||||
format: "PDF" | "HTML";
|
||||
};
|
||||
|
||||
export interface ReportStorage {
|
||||
exists(docKey: ReportStorageKey): Promise<boolean>;
|
||||
read(docKey: ReportStorageKey): Promise<Buffer | string>;
|
||||
write(docKey: ReportStorageKey, payload: Buffer | string): Promise<void>;
|
||||
}
|
||||
@ -7,12 +7,12 @@ import { logger } from "../logger";
|
||||
|
||||
export class SequelizeTransactionManager extends TransactionManager {
|
||||
protected _database: Sequelize | null = null;
|
||||
private readonly isolationLevel: string;
|
||||
private isolationLevel: Transaction.ISOLATION_LEVELS;
|
||||
|
||||
protected async _startTransaction(): Promise<Transaction> {
|
||||
// Sequelize adquiere una conexión del pool y la asocia a la transacción.
|
||||
return await this._database!.transaction({
|
||||
isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED,
|
||||
isolationLevel: this.isolationLevel,
|
||||
});
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ export class SequelizeTransactionManager extends TransactionManager {
|
||||
throw new InfrastructureError("[SequelizeTransactionManager] Database not available");
|
||||
}
|
||||
|
||||
constructor(database: Sequelize, options?: { isolationLevel?: string }) {
|
||||
constructor(database: Sequelize, options?: { isolationLevel?: Transaction.ISOLATION_LEVELS }) {
|
||||
super();
|
||||
this._database = database;
|
||||
this.isolationLevel = options?.isolationLevel ?? Transaction.ISOLATION_LEVELS.READ_COMMITTED;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AmountBaseSchema } from "./base.schemas";
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { MetadataSchema } from "./metadata.dto";
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { NumericStringSchema } from "./base.schemas";
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { NumericStringSchema } from "./base.schemas";
|
||||
|
||||
/**
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { PercentageDTO } from "./percentage.dto";
|
||||
import type { PercentageDTO } from "./percentage.dto";
|
||||
|
||||
export interface ITaxTypeDTO {
|
||||
id: string;
|
||||
|
||||
2
modules/core/src/common/types/dto.d.ts
vendored
Normal file
2
modules/core/src/common/types/dto.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// biome-ignore lint/style/useNamingConvention: false positive
|
||||
export type DTO<T = Record<string, unknown>> = T;
|
||||
@ -1 +1,2 @@
|
||||
export * from "./dto.d";
|
||||
export * from "./module-metadata.d";
|
||||
|
||||
@ -50,7 +50,7 @@ export class IssuedInvoiceTaxesReportPresenter extends Presenter<IssuedInvoiceTa
|
||||
this._locale = locale;
|
||||
this._taxCatalog = SpainTaxCatalogProvider();
|
||||
|
||||
return taxes.map((item, _index) => {
|
||||
return taxes?.map((item, _index) => {
|
||||
return this._mapTax(item);
|
||||
});
|
||||
}
|
||||
|
||||
@ -21,13 +21,13 @@ export class IssuedInvoiceReportPresenter extends Presenter<
|
||||
const itemsPresenter = this.presenterRegistry.getPresenter({
|
||||
resource: "issued-invoice-items",
|
||||
projection: "REPORT",
|
||||
format: "JSON",
|
||||
format: "DTO",
|
||||
});
|
||||
|
||||
const taxesPresenter = this.presenterRegistry.getPresenter({
|
||||
resource: "issued-invoice-taxes",
|
||||
projection: "REPORT",
|
||||
format: "JSON",
|
||||
format: "DTO",
|
||||
});
|
||||
|
||||
const locale = issuedInvoiceDTO.language_code;
|
||||
@ -49,6 +49,11 @@ export class IssuedInvoiceReportPresenter extends Presenter<
|
||||
taxes: taxesDTO,
|
||||
items: itemsDTO,
|
||||
|
||||
recipient: {
|
||||
...issuedInvoiceDTO.recipient,
|
||||
format_address: this.formatAddress(issuedInvoiceDTO.recipient),
|
||||
},
|
||||
|
||||
invoice_date: DateHelper.format(issuedInvoiceDTO.invoice_date, locale),
|
||||
subtotal_amount: MoneyDTOHelper.format(
|
||||
issuedInvoiceDTO.subtotal_amount,
|
||||
@ -70,6 +75,43 @@ export class IssuedInvoiceReportPresenter extends Presenter<
|
||||
total_amount: MoneyDTOHelper.format(issuedInvoiceDTO.total_amount, locale, moneyOptions),
|
||||
|
||||
payment_method: this._formatPaymentMethodDTO(issuedInvoiceDTO.payment_method),
|
||||
|
||||
verifactu: {
|
||||
...issuedInvoiceDTO.verifactu,
|
||||
qr_code: issuedInvoiceDTO.verifactu.qr_code.replace("data:image/png;base64,", ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected formatAddress(recipient: GetIssuedInvoiceByIdResponseDTO["recipient"]): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Líneas de calle
|
||||
if (recipient.street) {
|
||||
lines.push(recipient.street);
|
||||
}
|
||||
|
||||
if (recipient.street2) {
|
||||
lines.push(recipient.street2);
|
||||
}
|
||||
|
||||
// Ciudad + código postal
|
||||
const cityLine = [recipient.postal_code, recipient.city].filter(Boolean).join(" ");
|
||||
|
||||
if (cityLine) {
|
||||
lines.push(cityLine);
|
||||
}
|
||||
|
||||
// Provincia
|
||||
if (recipient.province) {
|
||||
lines.push(recipient.province);
|
||||
}
|
||||
|
||||
// País
|
||||
if (recipient.country) {
|
||||
lines.push(recipient.country);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,29 @@
|
||||
import type { IPresenterRegistry, ITransactionManager } from "@erp/core/api";
|
||||
import type { DTO } from "@erp/core";
|
||||
import type {
|
||||
IPresenterRegistry,
|
||||
IRendererRegistry,
|
||||
ITransactionManager,
|
||||
RendererFormat,
|
||||
} from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CustomerInvoice } from "../../../../domain";
|
||||
import type { CustomerInvoiceApplicationService } from "../../../services";
|
||||
|
||||
import type { IssuedInvoiceReportPDFPresenter } from "./reporter/issued-invoice.report.pdf";
|
||||
|
||||
type ReportIssuedInvoiceUseCaseInput = {
|
||||
companyId: UniqueID;
|
||||
companySlug: string;
|
||||
invoice_id: string;
|
||||
format: "pdf" | "html";
|
||||
format: RendererFormat;
|
||||
};
|
||||
|
||||
export class ReportIssuedInvoiceUseCase {
|
||||
constructor(
|
||||
private readonly service: CustomerInvoiceApplicationService,
|
||||
private readonly transactionManager: ITransactionManager,
|
||||
private readonly presenterRegistry: IPresenterRegistry
|
||||
private readonly presenterRegistry: IPresenterRegistry,
|
||||
private readonly rendererRegistry: IRendererRegistry
|
||||
) {}
|
||||
|
||||
public async execute(params: ReportIssuedInvoiceUseCaseInput) {
|
||||
@ -30,11 +36,21 @@ export class ReportIssuedInvoiceUseCase {
|
||||
}
|
||||
|
||||
const invoiceId = idOrError.data;
|
||||
const pdfPresenter = this.presenterRegistry.getPresenter({
|
||||
|
||||
const fullPresenter = this.presenterRegistry.getPresenter<CustomerInvoice>({
|
||||
resource: "issued-invoice",
|
||||
projection: "FULL",
|
||||
});
|
||||
|
||||
const reportPresenter = this.presenterRegistry.getPresenter<DTO>({
|
||||
resource: "issued-invoice",
|
||||
projection: "REPORT",
|
||||
});
|
||||
|
||||
const renderer = this.rendererRegistry.getRenderer<DTO>({
|
||||
resource: "issued-invoice",
|
||||
format,
|
||||
}) as IssuedInvoiceReportPDFPresenter;
|
||||
});
|
||||
|
||||
return this.transactionManager.complete(async (transaction) => {
|
||||
try {
|
||||
@ -49,18 +65,33 @@ export class ReportIssuedInvoiceUseCase {
|
||||
}
|
||||
|
||||
const invoice = invoiceOrError.data;
|
||||
const reportData = await pdfPresenter.toOutput(invoice, { companySlug });
|
||||
const documentId = `${invoice.series.getOrUndefined()}${invoice.invoiceNumber.toString()}`;
|
||||
|
||||
if (format === "html") {
|
||||
const invoiceDTO = await fullPresenter.toOutput(invoice, { companySlug });
|
||||
|
||||
const reportInvoiceDTO = await reportPresenter.toOutput(invoiceDTO, { companySlug });
|
||||
|
||||
const result = (await renderer.render(reportInvoiceDTO, {
|
||||
companySlug,
|
||||
documentId,
|
||||
})) as unknown;
|
||||
|
||||
if (result.isFailure) {
|
||||
return Result.fail(result.error);
|
||||
}
|
||||
|
||||
const { payload: invoiceRendered } = result.data;
|
||||
|
||||
if (format === "HTML") {
|
||||
return Result.ok({
|
||||
data: String(reportData),
|
||||
data: String(invoiceRendered),
|
||||
filename: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
data: reportData as Buffer<ArrayBuffer>,
|
||||
filename: `proforma-${invoice.invoiceNumber}.pdf`,
|
||||
data: invoiceRendered as Buffer<ArrayBuffer>,
|
||||
filename: `customer-invoice-${invoice.invoiceNumber}.pdf`,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(error as Error);
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./issued-invoice.report.html";
|
||||
export * from "./issued-invoice.report.pdf";
|
||||
@ -1,35 +0,0 @@
|
||||
import { TemplatePresenter } from "@erp/core/api";
|
||||
|
||||
import type { CustomerInvoice } from "../../../../../domain";
|
||||
import type {
|
||||
IssuedInvoiceFullPresenter,
|
||||
IssuedInvoiceReportPresenter,
|
||||
} from "../../../../presenters";
|
||||
|
||||
export class IssuedInvoiceReportHTMLPresenter extends TemplatePresenter {
|
||||
toOutput(invoice: CustomerInvoice, params: { companySlug: string }): string {
|
||||
const { companySlug } = params;
|
||||
const dtoPresenter = this.presenterRegistry.getPresenter({
|
||||
resource: "issued-invoice",
|
||||
projection: "FULL",
|
||||
}) as IssuedInvoiceFullPresenter;
|
||||
|
||||
const prePresenter = this.presenterRegistry.getPresenter({
|
||||
resource: "issued-invoice",
|
||||
projection: "REPORT",
|
||||
format: "JSON",
|
||||
}) as IssuedInvoiceReportPresenter;
|
||||
|
||||
const invoiceDTO = dtoPresenter.toOutput(invoice);
|
||||
const prettyDTO = prePresenter.toOutput(invoiceDTO);
|
||||
|
||||
// Obtener y compilar la plantilla HTML
|
||||
const template = this.templateResolver.compileTemplate(
|
||||
"customer-invoices",
|
||||
companySlug,
|
||||
"issued-invoice.hbs"
|
||||
);
|
||||
|
||||
return template(prettyDTO);
|
||||
}
|
||||
}
|
||||
@ -1,79 +0,0 @@
|
||||
import { Presenter } from "@erp/core/api";
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
import type { CustomerInvoice } from "../../../../../domain";
|
||||
|
||||
import type { IssuedInvoiceReportHTMLPresenter } from "./issued-invoice.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 IssuedInvoiceReportPDFPresenter extends Presenter<
|
||||
CustomerInvoice,
|
||||
Promise<Buffer<ArrayBuffer>>
|
||||
> {
|
||||
async toOutput(
|
||||
invoice: CustomerInvoice,
|
||||
params: { companySlug: string }
|
||||
): Promise<Buffer<ArrayBuffer>> {
|
||||
try {
|
||||
const htmlPresenter = this.presenterRegistry.getPresenter({
|
||||
resource: "issued-invoice",
|
||||
projection: "REPORT",
|
||||
format: "HTML",
|
||||
}) as IssuedInvoiceReportHTMLPresenter;
|
||||
|
||||
const htmlData = htmlPresenter.toOutput(invoice, 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: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
export * from "./proforma.report.html";
|
||||
export * from "./proforma.report.pdf";
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
@ -1,344 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.css"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>Factura F26200</title>
|
||||
<style>
|
||||
/* ---------------------------- */
|
||||
/* ESTRUCTURA CABECERA */
|
||||
/* ---------------------------- */
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
/* Fila superior */
|
||||
.top-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Bloque izquierdo */
|
||||
.left-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 70px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.company-text {
|
||||
font-size: 7pt;
|
||||
line-height: 1.2;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
/* Bloque derecho */
|
||||
.right-block {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.factura-img {
|
||||
height: 45px;
|
||||
}
|
||||
|
||||
/* Fila inferior */
|
||||
.bottom-header {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Cuadros */
|
||||
.info-box {
|
||||
border: 1px solid black;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.info-dire {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
/* ---------------------------- */
|
||||
/* ESTRUCTURA BODY */
|
||||
/* ---------------------------- */
|
||||
|
||||
body {
|
||||
font-family: Tahoma, sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
font-size: 9pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
border-top: 0px solid;
|
||||
border-left: 1px solid #000;
|
||||
border-right: 1px solid #000;
|
||||
border-bottom: 0px solid;
|
||||
padding: 3px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table th {
|
||||
margin-bottom: 10px;
|
||||
border-top: 1px solid #000;
|
||||
border-bottom: 1px solid #000;
|
||||
text-align: center;
|
||||
background-color: #e7e0df;
|
||||
color: #ff0014;
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totals td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.totals td.label {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.resume-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 9pt;
|
||||
font-family: Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
/* Columna izquierda (notas / forma de pago) */
|
||||
.left-col {
|
||||
width: 70%;
|
||||
vertical-align: top;
|
||||
padding: 10px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
/* Etiquetas */
|
||||
.resume-table .label {
|
||||
width: 15%;
|
||||
padding: 6px 8px;
|
||||
text-align: right;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
/* Valores numéricos */
|
||||
.resume-table .value {
|
||||
width: 15%;
|
||||
padding: 6px 8px;
|
||||
text-align: right;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
/* Total factura */
|
||||
.total-row .label,
|
||||
.total-row .value {
|
||||
background-color: #eee;
|
||||
font-size: 9pt;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.total {
|
||||
color: #d10000;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.resume-table .empty {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
|
||||
<!-- FILA SUPERIOR: logo + dirección / imagen factura -->
|
||||
<div class="top-header">
|
||||
<div class="left-block">
|
||||
<img src="https://rodax-software.com/images/logo_acana.jpg" alt="Logo Acana" class="logo" />
|
||||
|
||||
<div class="company-text">
|
||||
<p>Aliso Design S.L. B86913910</p>
|
||||
<p>C/ La Fundición, 27. Pol. Santa Ana</p>
|
||||
<p>Rivas Vaciamadrid 28522 Madrid</p>
|
||||
<p>Telf: 91 301 65 57 / 91 301 65 58</p>
|
||||
<p>
|
||||
<a href="mailto:info@acanainteriorismo.com">info@acanainteriorismo.com</a> -
|
||||
<a href="https://www.acanainteriorismo.com" target="_blank">www.acanainteriorismo.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-block">
|
||||
<img src="https://rodax-software.com/images/factura_acana.jpg" alt="Factura" class="factura-img" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FILA INFERIOR: cuadro factura + cuadro cliente -->
|
||||
<div class="bottom-header">
|
||||
|
||||
<div class="info-box">
|
||||
<p>Factura nº: <strong>{{series}}{{invoice_number}}</strong></p>
|
||||
<p>Fecha: <strong>{{invoice_date}}</strong></p>
|
||||
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
|
||||
</div>
|
||||
|
||||
<div class="info-box info-dire">
|
||||
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
|
||||
<p>{{recipient.tin}}</p>
|
||||
<p>{{recipient.street}}</p>
|
||||
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="details">
|
||||
|
||||
|
||||
<!-- Tu tabla -->
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="py-2">Concepto</th>
|
||||
<th class="py-2">Ud.</th>
|
||||
<th class="py-2">Imp.</th>
|
||||
<th class="py-2"> </th>
|
||||
<th class="py-2">Imp. total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if discount_percentage}}{{discount_percentage}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/if}}</td>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
||||
<tr class="resume-table">
|
||||
<!-- Columna izquierda: notas y forma de pago -->
|
||||
<td class="left-col" rowspan="10">
|
||||
{{#if payment_method}}
|
||||
<p><strong>Forma de pago:</strong> {{payment_method}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if notes}}
|
||||
<p class="mt-2"><strong>Notas:</strong> {{notes}}</p>
|
||||
{{/if}}
|
||||
</td>
|
||||
<!-- Columna derecha: totales -->
|
||||
{{#if discount_percentage}}
|
||||
<td colspan="2" class="label">Importe neto</td>
|
||||
<td colspan="2" class="value">{{subtotal_amount}}</td>
|
||||
{{else}}
|
||||
<td colspan="2" class="label">Base imponible</td>
|
||||
<td colspan="2" class="value">{{taxable_amount}}</td>
|
||||
{{/if}}
|
||||
</tr>
|
||||
|
||||
{{#if discount_percentage}}
|
||||
<tr class="resume-table">
|
||||
<td colspan="2" class="label">Dto {{discount_percentage}}</td>
|
||||
<td colspan="2" class="value">{{discount_amount.value}}</td>
|
||||
</tr>
|
||||
<tr class="resume-table">
|
||||
<td colspan="2" class="label">Base imponible</td>
|
||||
<td colspan="2" class="value">{{taxable_amount}}</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
{{#each taxes}}
|
||||
<tr class="resume-table">
|
||||
<td colspan="2" class="label">{{tax_name}}</td>
|
||||
<td colspan="2" class="value">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
||||
<tr class="total-row">
|
||||
<td colspan="2" class="label"><strong>Total factura</strong></td>
|
||||
<td colspan="2" class="value total"><strong>{{total_amount}}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<footer id="footer" class="mt-4 border-t border-black">
|
||||
<aside class="mt-4">
|
||||
<tfoot>
|
||||
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 31.839, Libro 0, Folio 191, Sección 8, Hoja
|
||||
M-572991
|
||||
CIF: B86913910</p>
|
||||
<p class="text-left" style="font-size: 6pt;">Información en protección de datos<br />De conformidad con lo
|
||||
dispuesto en el RGPD y LOPDGDD,
|
||||
informamos que los datos personales serán tratados por
|
||||
ALISO DESIGN S.L para cumplir con la obligación tributaria de emitir facturas. Podrá solicitar más
|
||||
información, y ejercer sus derechos escribiendo a info@acanainteriorismo.com o mediante correo postal a la
|
||||
dirección CALLE
|
||||
LA FUNDICION 27 POL. IND. SANTA ANA (28522) RIVAS-VACIAMADRID, MADRID. Para el ejercicio de sus derechos, en
|
||||
caso
|
||||
de que sea necesario, se le solicitará documento que acredite su identidad. Si siente vulnerados sus derechos
|
||||
puede presentar una reclamación ante la AEPD, en su web: www.aepd.es.</p>
|
||||
</tfoot>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,152 +0,0 @@
|
||||
<html lang="{{lang_code}}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>Presupuesto #{{id}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: #000;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#header {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#footer {}
|
||||
|
||||
@media print {
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header id="header">
|
||||
<aside class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold">DISTRIBUIDOR OFICIAL</h3>
|
||||
<div id="logo" class="w-32">
|
||||
<svg viewBox="0 0 336 133" fill="none" xmlns="http://www.w3.org/2000/svg" class="uecko-logo">
|
||||
<path
|
||||
d="M49.7002 83.0001H66.9002V22.5001H49.7002V56.2001C49.7002 64.3001 45.5002 68.5001 39.0002 68.5001C32.5002 68.5001 28.6002 64.3001 28.6002 56.2001V22.5001H0.700195V33.2001H11.4002V61.6001C11.4002 75.5001 19.0002 84.1001 31.9002 84.1001C40.6002 84.1001 45.7002 79.5001 49.6002 74.4001V83.0001H49.7002ZM120.6 48.0001H94.8002C96.2002 40.2001 100.8 35.1001 107.9 35.1001C115.1 35.2001 119.6 40.3001 120.6 48.0001ZM137.1 58.7001C137.2 57.1001 137.3 56.1001 137.3 54.4001V54.2001C137.3 37.0001 128 21.4001 107.8 21.4001C90.2002 21.4001 77.9002 35.6001 77.9002 52.9001V53.1001C77.9002 71.6001 91.3002 84.4001 109.5 84.4001C120.4 84.4001 128.6 80.1001 134.2 73.1001L124.4 64.4001C119.7 68.8001 115.5 70.6001 109.7 70.6001C102 70.6001 96.6002 66.5001 94.9002 58.7001H137.1ZM162.2 52.9001V52.7001C162.2 43.8001 168.3 36.2001 176.9 36.2001C183 36.2001 186.8 38.8001 190.7 42.9001L201.2 31.6001C195.6 25.3001 188.4 21.4001 177 21.4001C158.5 21.4001 145.3 35.6001 145.3 52.9001V53.1001C145.3 70.4001 158.6 84.4001 176.8 84.4001C188.9 84.4001 195.6 79.8001 201.5 73.3001L191.5 63.1001C187.3 67.1001 183.4 69.5001 177.6 69.5001C168.2 69.6001 162.2 62.1001 162.2 52.9001ZM269.1 83.0001L245.3 46.3001L268.3 22.5001H247.8L227.7 44.5001V0.600098H210.5V83.0001H227.7V64.6001L233.7 58.3001L249.5 83.0001H269.1ZM318.5 53.1001C318.5 62.0001 312.6 69.6001 302.8 69.6001C293.3 69.6001 286.9 61.8001 286.9 52.9001V52.7001C286.9 43.8001 292.8 36.2001 302.6 36.2001C312.1 36.2001 318.5 44.0001 318.5 52.9001V53.1001ZM335.4 52.9001V52.7001C335.4 35.3001 321.5 21.4001 302.8 21.4001C284 21.4001 270 35.5001 270 52.9001V53.1001C270 70.5001 283.9 84.4001 302.6 84.4001C321.4 84.4001 335.4 70.3001 335.4 52.9001Z"
|
||||
fill="black" class="uecko-logo"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="flex pb-2 space-x-4">
|
||||
<img id="dealer-logo" src={{dealer.logo}} alt="Logo distribuidor" />
|
||||
<address class="text-base not-italic font-medium whitespace-pre-line" id="from">{{dealer.contact_information}}
|
||||
</address>
|
||||
</section>
|
||||
<section class="grid grid-cols-2 gap-4 pb-4 mb-4 border-b">
|
||||
<aside>
|
||||
<p class="text-sm"><strong>Presupuesto nº:</strong> {{reference}}</p>
|
||||
<p class="text-sm"><strong>Fecha:</strong> {{date}}</p>
|
||||
<p class="text-sm"><strong>Validez:</strong> {{validity}}</p>
|
||||
<p class="text-sm"><strong>Vendedor:</strong> {{dealer.name}}</p>
|
||||
<p class="text-sm"><strong>Referencia cliente:</strong> {{customer_reference}}</p>
|
||||
</aside>
|
||||
<address class="text-base not-italic font-semibold whitespace-pre-line" id="to">{{customer_information}}
|
||||
</address>
|
||||
</section>
|
||||
<aside class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-2xl font-normal">PRESUPUESTO</h3>
|
||||
<div id="header-pagination">
|
||||
Página <span class="pageNumber"></span> de <span class="totalPages"></span>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
<main id="main">
|
||||
<section id="details">
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-2 py-2 text-right border">Cant.</th>
|
||||
<th class="px-2 py-2 border">Descripción</th>
|
||||
<th class="px-2 py-2 text-right border">Prec. Unitario</th>
|
||||
<th class="px-2 py-2 text-right border">Subtotal</th>
|
||||
<th class="px-2 py-2 text-right border">Dto (%)</th>
|
||||
<th class="px-2 py-2 text-right border">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table-body">
|
||||
{{#each items}}
|
||||
<tr class="text-sm border-b">
|
||||
<td class="content-start px-2 py-2 text-right">{{quantity}}</td>
|
||||
<td class="px-2 py-2 font-medium">{{description}}</td>
|
||||
<td class="content-start px-2 py-2 text-right">{{unit_price}}</td>
|
||||
<td class="content-start px-2 py-2 text-right">{{subtotal_price}}</td>
|
||||
<td class="content-start px-2 py-2 text-right">{{discount}}</td>
|
||||
<td class="content-start px-2 py-2 text-right">{{total_price}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
|
||||
|
||||
<div class="grow">
|
||||
<div class="pt-4">
|
||||
<p class="text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grow">
|
||||
<table class="min-w-full bg-transparent">
|
||||
<tbody>
|
||||
<tr class="border-b">
|
||||
<td class="px-4 py-2">Importe neto</td>
|
||||
<td class="px-4 py-2"></td>
|
||||
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-4 py-2">% Descuento</td>
|
||||
<td class="px-4 py-2 text-right border">{{discount.amount}}</td>
|
||||
<td class="px-4 py-2 text-right border">{{discount_price}}</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-4 py-2">Base imponible</td>
|
||||
<td class="px-4 py-2"></td>
|
||||
<td class="px-4 py-2 text-right border">{{before_tax_price}}</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-4 py-2">% IVA</td>
|
||||
<td class="px-4 py-2 text-right border">{{tax}}</td>
|
||||
<td class="px-4 py-2 text-right border">{{tax_price}}</td>
|
||||
</tr>
|
||||
<tr class="border-b">
|
||||
<td class="px-4 py-2">Importe total</td>
|
||||
<td class="px-4 py-2"></td>
|
||||
<td class="px-4 py-2 text-right border">{{total_price}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section id="legal_terms">
|
||||
<p class="text-xs text-gray-500">{{quote.default_legal_terms}}</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer id="footer" class="mt-4">
|
||||
<aside><img src="https://uecko.com/assets/img/uecko-footer_logos.jpg" class="w-full" /></aside>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,259 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.css"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>Factura F26200</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
.company-info,
|
||||
.invoice-meta {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.invoice-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.contact {
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
border: 0px solid;
|
||||
padding: 3px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table th {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totals td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.totals td.label {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: #eef;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header id="header">
|
||||
<aside class="flex items-start mb-4 w-full ">
|
||||
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
|
||||
<div class="flex flex-col items-start text-left" style="flex:0 0 70%;">
|
||||
<img src="https://rodax-software.com/images/logo1.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
|
||||
<div class="flex w-full">
|
||||
<div class="p-1 ">
|
||||
<p>Nº:<strong> {{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{invoice_date}}</strong></p>
|
||||
<p>Página <span class="pageNumber"></span> de <span class="totalPages"></span></p>
|
||||
</div>
|
||||
<div class="p-1 ml-28">
|
||||
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
|
||||
<p>{{recipient.tin}}</p>
|
||||
<p>{{recipient.street}}</p>
|
||||
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
|
||||
<div class="ml-auto flex flex-col items-end text-right h-full">
|
||||
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
|
||||
class="block h-5 w-auto md:h-8 mb-1" />
|
||||
<div class="not-italic text-xs leading-tight h-full">
|
||||
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
|
||||
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
|
||||
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
|
||||
class="hover:underline">www.rodax-software.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<h1>FACTURA PROFORMA</h1>
|
||||
<section id="details" class="border-b border-black ">
|
||||
<div class="relative pt-0 border-b border-black">
|
||||
<!-- Badge TOTAL decorado con imagen -->
|
||||
<div class="absolute -top-9 right-0">
|
||||
|
||||
<div class="relative text-sm font-semibold text-black pr-2 pl-10 py-2 justify-center bg-red-900"
|
||||
style="background-image: url('https://rodax-software.com/images/img-total2.jpg'); background-size: cover; background-position: left;">
|
||||
<!-- Texto del total -->
|
||||
<span>TOTAL: {{total_amount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tu tabla -->
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th class="py-2">Concepto</th>
|
||||
<th class="py-2">Cantidad</th>
|
||||
<th class="py-2">Precio unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if subtotal_amount}}{{subtotal_amount}}{{else}} {{/if}}</td>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
|
||||
|
||||
<div class="grow relative pt-10 self-start">
|
||||
{{#if payment_method}}
|
||||
<div class="">
|
||||
<p class=" text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty payment method-->
|
||||
{{/if}}
|
||||
{{#if notes}}
|
||||
<div class="pt-4">
|
||||
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty notes-->
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="relative pt-10 grow">
|
||||
<table class=" table-header min-w-full bg-transparent">
|
||||
<tbody>
|
||||
{{#if discount_percentage}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Importe neto</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{subtotal_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 text-right">Descuento {{discount_percentage}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{discount_amount.value}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<!-- dto 0-->
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Base imponible</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxable_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 text-right">IVA 21%</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
<tr class="">
|
||||
<td class="px-4 text-right accent-color">
|
||||
Total factura
|
||||
</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right accent-color">
|
||||
{{total_amount}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer id="footer" class="mt-4">
|
||||
<aside>
|
||||
<p class="text-left"><strong>Factura Proforma.</strong>
|
||||
Este documento es de carácter informativo y no tiene validez contable ni fiscal. Contiene precios y condiciones
|
||||
de venta sujetos a confirmación del cliente. Solo adquirirá validez como factura definitiva una vez aceptados
|
||||
dichos términos.</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,260 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.css"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>Factura F26200</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
.company-info,
|
||||
.invoice-meta {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.invoice-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.contact {
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
border: 0px solid;
|
||||
padding: 3px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table th {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totals td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.totals td.label {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: #eef;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<aside class="flex items-start mb-4 w-full">
|
||||
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
|
||||
<div class="w-[70%] flex flex-col items-start text-left">
|
||||
<img src="https://rodax-software.com/images/logo_rodax.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
|
||||
<div class="flex w-full">
|
||||
<div class="p-1 ">
|
||||
<p>Factura nº:<strong> {{series}}{{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{invoice_date}}</strong></p>
|
||||
</div>
|
||||
<div class="p-1 ml-9">
|
||||
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
|
||||
<p>{{recipient.tin}}</p>
|
||||
<p>{{recipient.street}}</p>
|
||||
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
|
||||
<div class="ml-auto flex flex-col items-end text-right">
|
||||
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
|
||||
class="block h-5 w-auto md:h-8 mb-1" />
|
||||
<div class="not-italic text-xs leading-tight">
|
||||
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
|
||||
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
|
||||
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
|
||||
class="hover:underline">www.rodax-software.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="details" class="border-b border-black ">
|
||||
|
||||
<div class="relative pt-0 border-b border-black">
|
||||
<!-- Badge TOTAL decorado con imagen -->
|
||||
<div class="absolute -top-9 right-0">
|
||||
|
||||
<div class="relative text-sm font-semibold text-black pr-2 pl-10 py-2 justify-center bg-red-900"
|
||||
style="background-image: url('https://rodax-software.com/images/img-total2.jpg'); background-size: cover; background-position: left;">
|
||||
<!-- Texto del total -->
|
||||
<span>TOTAL: {{total_amount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tu tabla -->
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th class="py-2">Concepto</th>
|
||||
<th class="py-2">Cantidad</th>
|
||||
<th class="py-2">Precio unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/if}}</td>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
|
||||
|
||||
<div class="grow relative pt-10 self-start">
|
||||
{{#if payment_method}}
|
||||
<div class="">
|
||||
<p class=" text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty payment method-->
|
||||
{{/if}}
|
||||
{{#if notes}}
|
||||
<div class="pt-4">
|
||||
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty notes-->
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="relative pt-10 grow">
|
||||
<table class=" table-header min-w-full bg-transparent">
|
||||
<tbody>
|
||||
{{#if discount_percentage}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Importe neto</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{subtotal_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 text-right">Descuento {{discount_percentage}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{discount_amount.value}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<!-- dto 0-->
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Base imponible</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxable_amount}}</td>
|
||||
</tr>
|
||||
{{#each taxes}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">{{tax_name}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
<tr class="">
|
||||
<td class="px-4 text-right accent-color">
|
||||
Total factura
|
||||
</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right accent-color">
|
||||
{{total_amount}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<footer id="footer" class="mt-4">
|
||||
<aside>
|
||||
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
|
||||
| CIF: B83999441 -
|
||||
Rodax Software S.L.</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB |
@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 336 133" fill="none" xmlns="http://www.w3.org/2000/svg" class="uecko-logo"><path d="M49.7002 83.0001H66.9002V22.5001H49.7002V56.2001C49.7002 64.3001 45.5002 68.5001 39.0002 68.5001C32.5002 68.5001 28.6002 64.3001 28.6002 56.2001V22.5001H0.700195V33.2001H11.4002V61.6001C11.4002 75.5001 19.0002 84.1001 31.9002 84.1001C40.6002 84.1001 45.7002 79.5001 49.6002 74.4001V83.0001H49.7002ZM120.6 48.0001H94.8002C96.2002 40.2001 100.8 35.1001 107.9 35.1001C115.1 35.2001 119.6 40.3001 120.6 48.0001ZM137.1 58.7001C137.2 57.1001 137.3 56.1001 137.3 54.4001V54.2001C137.3 37.0001 128 21.4001 107.8 21.4001C90.2002 21.4001 77.9002 35.6001 77.9002 52.9001V53.1001C77.9002 71.6001 91.3002 84.4001 109.5 84.4001C120.4 84.4001 128.6 80.1001 134.2 73.1001L124.4 64.4001C119.7 68.8001 115.5 70.6001 109.7 70.6001C102 70.6001 96.6002 66.5001 94.9002 58.7001H137.1ZM162.2 52.9001V52.7001C162.2 43.8001 168.3 36.2001 176.9 36.2001C183 36.2001 186.8 38.8001 190.7 42.9001L201.2 31.6001C195.6 25.3001 188.4 21.4001 177 21.4001C158.5 21.4001 145.3 35.6001 145.3 52.9001V53.1001C145.3 70.4001 158.6 84.4001 176.8 84.4001C188.9 84.4001 195.6 79.8001 201.5 73.3001L191.5 63.1001C187.3 67.1001 183.4 69.5001 177.6 69.5001C168.2 69.6001 162.2 62.1001 162.2 52.9001ZM269.1 83.0001L245.3 46.3001L268.3 22.5001H247.8L227.7 44.5001V0.600098H210.5V83.0001H227.7V64.6001L233.7 58.3001L249.5 83.0001H269.1ZM318.5 53.1001C318.5 62.0001 312.6 69.6001 302.8 69.6001C293.3 69.6001 286.9 61.8001 286.9 52.9001V52.7001C286.9 43.8001 292.8 36.2001 302.6 36.2001C312.1 36.2001 318.5 44.0001 318.5 52.9001V53.1001ZM335.4 52.9001V52.7001C335.4 35.3001 321.5 21.4001 302.8 21.4001C284 21.4001 270 35.5001 270 52.9001V53.1001C270 70.5001 283.9 84.4001 302.6 84.4001C321.4 84.4001 335.4 70.3001 335.4 52.9001Z" fill="black" class="uecko-logo"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { GetIssuedInvoiceUseCase } from "../../../../application";
|
||||
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||
@ -9,7 +14,11 @@ export class GetIssueInvoiceController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
|
||||
import type { ListIssuedInvoicesUseCase } from "../../../../application";
|
||||
@ -10,7 +15,11 @@ export class ListIssuedInvoicesController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
private getCriteriaWithDefaultOrder() {
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
type RendererFormat,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
import type { ReportIssueInvoiceByIdQueryRequestDTO } from "@erp/customer-invoices/common";
|
||||
|
||||
import type { ReportIssuedInvoiceUseCase } from "../../../../application";
|
||||
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||
@ -9,7 +16,11 @@ export class ReportIssuedInvoiceController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
@ -20,13 +31,26 @@ export class ReportIssuedInvoiceController extends ExpressController {
|
||||
|
||||
const { companySlug } = this.getUser();
|
||||
const { invoice_id } = this.req.params;
|
||||
const { format } = this.req.query as { format: "pdf" | "html" };
|
||||
const { format } = this.req.query as ReportIssueInvoiceByIdQueryRequestDTO;
|
||||
|
||||
const result = await this.useCase.execute({ invoice_id, companyId, companySlug, format });
|
||||
const result = await this.useCase.execute({
|
||||
invoice_id,
|
||||
companyId,
|
||||
companySlug,
|
||||
format: format as RendererFormat,
|
||||
});
|
||||
|
||||
return result.match(
|
||||
({ data, filename }) =>
|
||||
filename ? this.downloadPDF(data, filename) : this.downloadHTML(data as string),
|
||||
({ data, filename }) => {
|
||||
if (format === "PDF") {
|
||||
return this.downloadPDF(data as Buffer<ArrayBuffer>, String(filename));
|
||||
}
|
||||
if (format === "HTML") {
|
||||
return this.downloadHTML(data as string);
|
||||
}
|
||||
// JSON
|
||||
return this.json(data);
|
||||
},
|
||||
(err) => this.handleError(err)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { ChangeStatusProformaByIdRequestDTO } from "../../../../../common/dto";
|
||||
import type { ChangeStatusProformaUseCase } from "../../../../application";
|
||||
@ -10,7 +15,11 @@ export class ChangeStatusProformaController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { CreateProformaRequestDTO } from "../../../../../common/dto";
|
||||
import type { CreateProformaUseCase } from "../../../../application";
|
||||
@ -10,7 +15,11 @@ export class CreateProformaController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { DeleteProformaUseCase } from "../../../../application";
|
||||
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||
@ -9,7 +14,11 @@ export class DeleteProformaController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { GetProformaUseCase } from "../../../../application";
|
||||
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||
@ -9,7 +14,11 @@ export class GetProformaController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { IssueProformaUseCase } from "../../../../application";
|
||||
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||
@ -9,7 +14,11 @@ export class IssueProformaController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
|
||||
import type { ListProformasUseCase } from "../../../../application";
|
||||
@ -10,7 +15,11 @@ export class ListProformasController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
private getCriteriaWithDefaultOrder() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { ReportProformaUseCase } from "../../../../application";
|
||||
import { customerInvoicesApiErrorMapper } from "../../customer-invoices-api-error-mapper";
|
||||
@ -9,7 +14,11 @@ export class ReportProformaController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { ExpressController, authGuard, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { UpdateProformaByIdRequestDTO } from "../../../../../common/dto";
|
||||
import type { UpdateProformaUseCase } from "../../../../application";
|
||||
@ -10,7 +15,11 @@ export class UpdateProformaController extends ExpressController {
|
||||
this.errorMapper = customerInvoicesApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { type RequestWithAuth, enforceTenant, enforceUser, mockUser } from "@erp/auth/api";
|
||||
import { type ModuleParams, validateRequest } from "@erp/core/api";
|
||||
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
|
||||
import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api";
|
||||
import type { ILogger } from "@repo/rdx-logger";
|
||||
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
|
||||
import type { Sequelize } from "sequelize";
|
||||
@ -39,10 +39,10 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
|
||||
|
||||
router.use([
|
||||
(req: Request, res: Response, next: NextFunction) =>
|
||||
enforceUser()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
requireAuthenticated()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) =>
|
||||
enforceTenant()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
requireCompanyContext()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
]);
|
||||
|
||||
// ----------------------------------------------
|
||||
@ -74,6 +74,7 @@ export const issuedInvoicesRouter = (params: ModuleParams) => {
|
||||
//checkTabContext,
|
||||
validateRequest(ReportIssueInvoiceByIdParamsRequestSchema, "params"),
|
||||
validateRequest(ReportIssueInvoiceByIdQueryRequestSchema, "query"),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const useCase = deps.useCases.report_issued_invoice();
|
||||
const controller = new ReportIssuedInvoiceController(useCase);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { type RequestWithAuth, enforceTenant, enforceUser, mockUser } from "@erp/auth/api";
|
||||
import { type ModuleParams, validateRequest } from "@erp/core/api";
|
||||
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
|
||||
import { type ModuleParams, type RequestWithAuth, validateRequest } from "@erp/core/api";
|
||||
import type { ILogger } from "@repo/rdx-logger";
|
||||
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
|
||||
import type { Sequelize } from "sequelize";
|
||||
@ -32,11 +32,11 @@ import {
|
||||
|
||||
export const proformasRouter = (params: ModuleParams) => {
|
||||
const { app, baseRoutePath, logger } = params as {
|
||||
env: Record<string, any>;
|
||||
app: Application;
|
||||
database: Sequelize;
|
||||
baseRoutePath: string;
|
||||
logger: ILogger;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
const deps = buildProformasDependencies(params);
|
||||
@ -52,10 +52,10 @@ export const proformasRouter = (params: ModuleParams) => {
|
||||
|
||||
router.use([
|
||||
(req: Request, res: Response, next: NextFunction) =>
|
||||
enforceUser()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
requireAuthenticated()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) =>
|
||||
enforceTenant()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
requireCompanyContext()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
]);
|
||||
|
||||
// ----------------------------------------------
|
||||
|
||||
@ -4,13 +4,17 @@ import { type JsonTaxCatalogProvider, SpainTaxCatalogProvider } from "@erp/core"
|
||||
import type {
|
||||
IMapperRegistry,
|
||||
IPresenterRegistry,
|
||||
ITemplateResolver,
|
||||
IRendererRegistry,
|
||||
ModuleParams,
|
||||
} from "@erp/core/api";
|
||||
import {
|
||||
HandlebarsTemplateResolver,
|
||||
FastReportExecutableResolver,
|
||||
FastReportProcessRunner,
|
||||
FastReportTemplateResolver,
|
||||
FileSystemReportStorage,
|
||||
InMemoryMapperRegistry,
|
||||
InMemoryPresenterRegistry,
|
||||
InMemoryRendererRegistry,
|
||||
SequelizeTransactionManager,
|
||||
} from "@erp/core/api";
|
||||
|
||||
@ -28,12 +32,9 @@ import {
|
||||
ListIssuedInvoicesUseCase,
|
||||
ReportIssuedInvoiceUseCase,
|
||||
} from "../application";
|
||||
import {
|
||||
IssuedInvoiceReportHTMLPresenter,
|
||||
IssuedInvoiceReportPDFPresenter,
|
||||
} from "../application/use-cases/issued-invoices/report-issued-invoices/reporter";
|
||||
|
||||
import { CustomerInvoiceDomainMapper, CustomerInvoiceListMapper } from "./mappers";
|
||||
import { IssuedInvoiceReportPDFRenderer } from "./renderers/issued-invoice-report-pdf.renderer";
|
||||
import { CustomerInvoiceRepository } from "./sequelize";
|
||||
import { SequelizeInvoiceNumberGenerator } from "./services";
|
||||
|
||||
@ -41,9 +42,9 @@ export type IssuedInvoicesDeps = {
|
||||
transactionManager: SequelizeTransactionManager;
|
||||
mapperRegistry: IMapperRegistry;
|
||||
presenterRegistry: IPresenterRegistry;
|
||||
rendererRegistry: IRendererRegistry;
|
||||
repo: CustomerInvoiceRepository;
|
||||
appService: CustomerInvoiceApplicationService;
|
||||
templateResolver: ITemplateResolver;
|
||||
catalogs: {
|
||||
taxes: JsonTaxCatalogProvider;
|
||||
};
|
||||
@ -55,13 +56,14 @@ export type IssuedInvoicesDeps = {
|
||||
};
|
||||
|
||||
export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInvoicesDeps {
|
||||
const { database, templateRootPath } = params;
|
||||
const { database, env } = params;
|
||||
const templateRootPath = env.TEMPLATES_PATH;
|
||||
const documentRootPath = env.DOCUMENTS_PATH;
|
||||
|
||||
/** Dominio */
|
||||
const catalogs = { taxes: SpainTaxCatalogProvider() };
|
||||
|
||||
/** Infraestructura */
|
||||
const templateResolver = new HandlebarsTemplateResolver(templateRootPath);
|
||||
const transactionManager = new SequelizeTransactionManager(database);
|
||||
|
||||
const mapperRegistry = new InMemoryMapperRegistry();
|
||||
@ -81,6 +83,25 @@ export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInv
|
||||
const repository = new CustomerInvoiceRepository({ mapperRegistry, database });
|
||||
const numberGenerator = new SequelizeInvoiceNumberGenerator();
|
||||
|
||||
// Renderers Registry
|
||||
const frExecResolver = new FastReportExecutableResolver();
|
||||
const frProcessRunner = new FastReportProcessRunner();
|
||||
const frTemplateResolver = new FastReportTemplateResolver(templateRootPath);
|
||||
|
||||
const rendererRegistry = new InMemoryRendererRegistry();
|
||||
const reportStorage = new FileSystemReportStorage(documentRootPath);
|
||||
rendererRegistry.registerRenderers([
|
||||
{
|
||||
key: { resource: "issued-invoice", format: "PDF" },
|
||||
renderer: new IssuedInvoiceReportPDFRenderer(
|
||||
frExecResolver,
|
||||
frProcessRunner,
|
||||
frTemplateResolver,
|
||||
reportStorage
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
/** Aplicación */
|
||||
const appService = new CustomerInvoiceApplicationService(repository, numberGenerator);
|
||||
|
||||
@ -113,25 +134,17 @@ export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInv
|
||||
|
||||
// REPORT
|
||||
{
|
||||
key: { resource: "issued-invoice", projection: "REPORT", format: "JSON" },
|
||||
key: { resource: "issued-invoice", projection: "REPORT", format: "DTO" },
|
||||
presenter: new IssuedInvoiceReportPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: { resource: "issued-invoice-taxes", projection: "REPORT", format: "JSON" },
|
||||
key: { resource: "issued-invoice-taxes", projection: "REPORT", format: "DTO" },
|
||||
presenter: new IssuedInvoiceTaxesReportPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: { resource: "issued-invoice-items", projection: "REPORT", format: "JSON" },
|
||||
key: { resource: "issued-invoice-items", projection: "REPORT", format: "DTO" },
|
||||
presenter: new IssuedInvoiceItemsReportPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: { resource: "issued-invoice", projection: "REPORT", format: "HTML" },
|
||||
presenter: new IssuedInvoiceReportHTMLPresenter(presenterRegistry, templateResolver),
|
||||
},
|
||||
{
|
||||
key: { resource: "issued-invoice", projection: "REPORT", format: "PDF" },
|
||||
presenter: new IssuedInvoiceReportPDFPresenter(presenterRegistry),
|
||||
},
|
||||
]);
|
||||
|
||||
const useCases: IssuedInvoicesDeps["useCases"] = {
|
||||
@ -141,7 +154,12 @@ export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInv
|
||||
get_issued_invoice: () =>
|
||||
new GetIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
|
||||
report_issued_invoice: () =>
|
||||
new ReportIssuedInvoiceUseCase(appService, transactionManager, presenterRegistry),
|
||||
new ReportIssuedInvoiceUseCase(
|
||||
appService,
|
||||
transactionManager,
|
||||
presenterRegistry,
|
||||
rendererRegistry
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
@ -149,9 +167,9 @@ export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInv
|
||||
repo: repository,
|
||||
mapperRegistry,
|
||||
presenterRegistry,
|
||||
rendererRegistry,
|
||||
appService,
|
||||
catalogs,
|
||||
templateResolver,
|
||||
useCases,
|
||||
};
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import {
|
||||
HandlebarsTemplateResolver,
|
||||
type IMapperRegistry,
|
||||
type IPresenterRegistry,
|
||||
type ITemplateResolver,
|
||||
InMemoryMapperRegistry,
|
||||
InMemoryPresenterRegistry,
|
||||
type ModuleParams,
|
||||
@ -22,8 +21,6 @@ import {
|
||||
ListProformasUseCase,
|
||||
ProformaFullPresenter,
|
||||
ProformaListPresenter,
|
||||
ProformaReportHTMLPresenter,
|
||||
ProformaReportPDFPresenter,
|
||||
ReportProformaUseCase,
|
||||
UpdateProformaUseCase,
|
||||
} from "../application";
|
||||
@ -48,7 +45,6 @@ export type ProformasDeps = {
|
||||
catalogs: {
|
||||
taxes: JsonTaxCatalogProvider;
|
||||
};
|
||||
templateResolver: ITemplateResolver;
|
||||
useCases: {
|
||||
list_proformas: () => ListProformasUseCase;
|
||||
get_proforma: () => GetProformaUseCase;
|
||||
@ -62,7 +58,8 @@ export type ProformasDeps = {
|
||||
};
|
||||
|
||||
export function buildProformasDependencies(params: ModuleParams): ProformasDeps {
|
||||
const { database, templateRootPath } = params;
|
||||
const { database, env } = params;
|
||||
const templateRootPath = env.TEMPLATES_PATH;
|
||||
|
||||
/** Dominio */
|
||||
const catalogs = { taxes: SpainTaxCatalogProvider() };
|
||||
@ -116,25 +113,17 @@ export function buildProformasDependencies(params: ModuleParams): ProformasDeps
|
||||
|
||||
// REPORT
|
||||
{
|
||||
key: { resource: "proforma", projection: "REPORT", format: "JSON" },
|
||||
key: { resource: "proforma", projection: "REPORT" },
|
||||
presenter: new ProformaReportPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: { resource: "proforma-taxes", projection: "REPORT", format: "JSON" },
|
||||
key: { resource: "proforma-taxes", projection: "REPORT" },
|
||||
presenter: new IssuedInvoiceTaxesReportPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: { resource: "proforma-items", projection: "REPORT", format: "JSON" },
|
||||
key: { resource: "proforma-items", projection: "REPORT" },
|
||||
presenter: new ProformaItemsReportPresenter(presenterRegistry),
|
||||
},
|
||||
{
|
||||
key: { resource: "proforma", projection: "REPORT", format: "HTML" },
|
||||
presenter: new ProformaReportHTMLPresenter(presenterRegistry, templateResolver),
|
||||
},
|
||||
{
|
||||
key: { resource: "proforma", projection: "REPORT", format: "PDF" },
|
||||
presenter: new ProformaReportPDFPresenter(presenterRegistry),
|
||||
},
|
||||
]);
|
||||
|
||||
const useCases: ProformasDeps["useCases"] = {
|
||||
@ -160,7 +149,6 @@ export function buildProformasDependencies(params: ModuleParams): ProformasDeps
|
||||
mapperRegistry,
|
||||
presenterRegistry,
|
||||
appService,
|
||||
templateResolver,
|
||||
catalogs,
|
||||
useCases,
|
||||
};
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export * from "./issued-invoice.renderer.html";
|
||||
export * from "./issued-invoice.renderer.json";
|
||||
export * from "./issued-invoice-report-pdf.renderer";
|
||||
@ -0,0 +1,36 @@
|
||||
import type { DTO } from "@erp/core";
|
||||
import { FastReportRenderer } from "@erp/core/api";
|
||||
|
||||
export type IssuedInvoiceReportPDFRendererParams = {
|
||||
companySlug: string;
|
||||
documentId: string;
|
||||
};
|
||||
|
||||
export class IssuedInvoiceReportPDFRenderer extends FastReportRenderer {
|
||||
protected readonly templateName = "issued-invoice.frx";
|
||||
protected companySlug!: string;
|
||||
|
||||
render(source: DTO, params: IssuedInvoiceReportPDFRendererParams) {
|
||||
this.companySlug = params.companySlug;
|
||||
|
||||
return this.renderInternal({
|
||||
inputData: source,
|
||||
format: "PDF",
|
||||
storageKey: {
|
||||
documentType: "customer-invoice",
|
||||
documentId: params.documentId,
|
||||
format: "PDF",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected resolveTemplatePath(): string {
|
||||
const templatePath = this.templateResolver.resolveTemplatePath(
|
||||
"customer-invoices",
|
||||
this.companySlug,
|
||||
this.templateName
|
||||
);
|
||||
|
||||
return templatePath;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import { TemplateRenderer } from "@erp/core/api";
|
||||
|
||||
import type { CustomerInvoice } from "../../domain";
|
||||
|
||||
import type { IssuedInvoiceReportJSONRenderer } from "./issued-invoice.renderer.json";
|
||||
|
||||
export class IssuedInvoiceReportHTMLRenderer extends TemplateRenderer {
|
||||
toOutput(invoice: CustomerInvoice, params: { companySlug: string }): string {
|
||||
const { companySlug } = params;
|
||||
|
||||
const jsonRenderer = this.presenterRegistry.getRenderer({
|
||||
resource: "issued-invoice",
|
||||
projection: "REPORT",
|
||||
format: "JSON",
|
||||
}) as IssuedInvoiceReportJSONRenderer;
|
||||
|
||||
const jsonData = jsonRenderer.toOutput(invoice, params);
|
||||
|
||||
// Obtener y compilar la plantilla HTML
|
||||
const template = this.templateResolver.compileTemplate(
|
||||
"customer-invoices",
|
||||
companySlug,
|
||||
"issued-invoice.hbs"
|
||||
);
|
||||
|
||||
return template(jsonData);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Renderer } from "@erp/core/api";
|
||||
|
||||
import type {
|
||||
IssuedInvoiceFullRenderer,
|
||||
IssuedInvoiceReportRenderer,
|
||||
} from "../../application/presenters";
|
||||
import type { CustomerInvoice } from "../../domain";
|
||||
|
||||
export class IssuedInvoiceReportJSONRenderer extends Renderer<
|
||||
CustomerInvoice,
|
||||
Record<string, any>
|
||||
> {
|
||||
toOutput(invoice: CustomerInvoice, params: { companySlug: string }): Record<string, any> {
|
||||
const dtoRenderer = this.presenterRegistry.getRenderer({
|
||||
resource: "issued-invoice",
|
||||
projection: "FULL",
|
||||
format: "DTO",
|
||||
}) as IssuedInvoiceFullRenderer;
|
||||
|
||||
const preRenderer = this.presenterRegistry.getRenderer({
|
||||
resource: "issued-invoice",
|
||||
projection: "REPORT",
|
||||
format: "DTO",
|
||||
}) as IssuedInvoiceReportRenderer;
|
||||
|
||||
const invoiceDTO = dtoRenderer.toOutput(invoice);
|
||||
const prettyDTO = preRenderer.toOutput(invoiceDTO);
|
||||
|
||||
return prettyDTO;
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,12 @@ export type ReportIssueInvoiceByIdParamsRequestDTO = z.infer<
|
||||
>;
|
||||
|
||||
export const ReportIssueInvoiceByIdQueryRequestSchema = z.object({
|
||||
format: z.enum(["pdf", "html"]).default("pdf"),
|
||||
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 ReportIssueInvoiceByIdQueryRequestDTO = z.infer<
|
||||
|
||||
229
modules/customer-invoices/templates/rodax/issued-invoice.frx
Normal file
229
modules/customer-invoices/templates/rodax/issued-invoice.frx
Normal file
File diff suppressed because one or more lines are too long
@ -1,297 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.css"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>Factura F26200</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
.company-info,
|
||||
.invoice-meta {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
/* Fila superior */
|
||||
.top-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Bloque izquierdo */
|
||||
.left-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Bloque derecho */
|
||||
.right-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* uno encima de otro */
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
/* o flex-start / center según quieras */
|
||||
justify-content: flex-start;
|
||||
width: 42%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.invoice-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.contact {
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
border: 0px solid;
|
||||
padding: 3px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table th {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totals td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.totals td.label {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: #eef;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="top-header">
|
||||
<div class="left-block">
|
||||
<img src="https://rodax-software.com/images/logo_rodax.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
|
||||
<div class="flex w-full">
|
||||
<div class="p-1 ">
|
||||
<p>Factura nº:<strong> {{series}}{{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{invoice_date}}</strong></p>
|
||||
</div>
|
||||
<div class="p-1 ml-9">
|
||||
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
|
||||
<p>{{recipient.tin}}</p>
|
||||
<p>{{recipient.street}}</p>
|
||||
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
|
||||
<div class="right-block">
|
||||
<div>
|
||||
<img src="{{asset 'logo2.jpg'}}" alt="Factura" class="factura-img" class="block h-5 w-auto md:h-8 mb-1"/>
|
||||
</div>
|
||||
<div class="not-italic leading-tight" style="font-size: 9px ;">
|
||||
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
|
||||
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
|
||||
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
|
||||
class="hover:underline">www.rodax-software.com</a></p>
|
||||
</div>
|
||||
{{#if verifactu.qr_code}}
|
||||
<div style="display: flex; align-items: center; gap: 2px; align-content: stretch">
|
||||
<div style="font-size: 9px ; text-align: right; flex-grow: 1; flex-shrink: 1; flex-basis: auto;">
|
||||
QR tributario factura verificable en sede electronica de AEAT VERI*FACTU
|
||||
</div>
|
||||
<div style="flex-grow: 0; flex-shrink: 0; flex-basis:100px;">
|
||||
<img src="{{verifactu.qr_code}}" alt="QR factura" style="width: 100px; height: 100px;" />
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="details" class="border-b border-black ">
|
||||
|
||||
<div class="relative pt-0 border-b border-black">
|
||||
<!-- Badge TOTAL decorado con imagen -->
|
||||
<div class="absolute -top-9 right-0">
|
||||
<div class="relative text-sm font-semibold text-black pr-2 pl-10 py-2 justify-center bg-red-900"
|
||||
style="background-image: url('https://rodax-software.com/images/img-total2.jpg'); background-size: cover; background-position: left;">
|
||||
<!-- Texto del total -->
|
||||
<span>TOTAL: {{total_amount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tu tabla -->
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th class="py-2">Concepto</th>
|
||||
<th class="py-2">Cantidad</th>
|
||||
<th class="py-2">Precio unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/if}}</td>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
|
||||
|
||||
<div class="grow relative pt-10 self-start">
|
||||
{{#if payment_method}}
|
||||
<div class="">
|
||||
<p class=" text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty payment method-->
|
||||
{{/if}}
|
||||
{{#if notes}}
|
||||
<div class="pt-4">
|
||||
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty notes-->
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="relative pt-10 grow">
|
||||
<table class=" table-header min-w-full bg-transparent">
|
||||
<tbody>
|
||||
{{#if discount_percentage}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Importe neto</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{subtotal_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 text-right">Descuento {{discount_percentage}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{discount_amount.value}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<!-- dto 0-->
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Base imponible</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxable_amount}}</td>
|
||||
</tr>
|
||||
{{#each taxes}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">{{tax_name}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
<tr class="">
|
||||
<td class="px-4 text-right accent-color">
|
||||
Total factura
|
||||
</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right accent-color">
|
||||
{{total_amount}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<footer id="footer" class="mt-4">
|
||||
<aside>
|
||||
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
|
||||
| CIF: B83999441 -
|
||||
Rodax Software S.L.</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
@ -1,260 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.css"
|
||||
referrerpolicy="no-referrer" />
|
||||
<title>Factura F26200</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
font-size: 11pt;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
.company-info,
|
||||
.invoice-meta {
|
||||
width: 48%;
|
||||
}
|
||||
|
||||
.invoice-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.contact {
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
table th,
|
||||
table td {
|
||||
border: 0px solid;
|
||||
padding: 3px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table th {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.totals {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totals td {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.totals td.label {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-color: #eef;
|
||||
}
|
||||
|
||||
.accent-color {
|
||||
background-color: #F08119;
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
display: table-footer-group;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<aside class="flex items-start mb-4 w-full">
|
||||
<!-- Bloque IZQUIERDO: imagen arriba + texto abajo, alineado a la izquierda -->
|
||||
<div class="w-[70%] flex flex-col items-start text-left">
|
||||
<img src="https://rodax-software.com/images/logo_rodax.jpg" alt="Logo Rodax" class="block h-14 w-auto mb-1" />
|
||||
<div class="flex w-full">
|
||||
<div class="p-1 ">
|
||||
<p>Factura nº:<strong> {{series}}{{invoice_number}}</strong></p>
|
||||
<p><span>Fecha:<strong> {{invoice_date}}</strong></p>
|
||||
</div>
|
||||
<div class="p-1 ml-9">
|
||||
<h2 class="font-semibold uppercase mb-1">{{recipient.name}}</h2>
|
||||
<p>{{recipient.tin}}</p>
|
||||
<p>{{recipient.street}}</p>
|
||||
<p>{{recipient.postal_code}} {{recipient.city}} {{recipient.province}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloque DERECHO: logo2 arriba y texto DEBAJO -->
|
||||
<div class="ml-auto flex flex-col items-end text-right">
|
||||
<img src="https://rodax-software.com/images/logo2.jpg" alt="Logo secundario"
|
||||
class="block h-5 w-auto md:h-8 mb-1" />
|
||||
<div class="not-italic text-xs leading-tight">
|
||||
<p>Telf: 91 785 02 47 / 686 62 10 59</p>
|
||||
<p><a href="mailto:info@rodax-software.com" class="hover:underline">info@rodax-software.com</a></p>
|
||||
<p><a href="https://www.rodax-software.com" target="_blank" rel="noopener"
|
||||
class="hover:underline">www.rodax-software.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="details" class="border-b border-black ">
|
||||
|
||||
<div class="relative pt-0 border-b border-black">
|
||||
<!-- Badge TOTAL decorado con imagen -->
|
||||
<div class="absolute -top-9 right-0">
|
||||
|
||||
<div class="relative text-sm font-semibold text-black pr-2 pl-10 py-2 justify-center bg-red-900"
|
||||
style="background-image: url('https://rodax-software.com/images/img-total2.jpg'); background-size: cover; background-position: left;">
|
||||
<!-- Texto del total -->
|
||||
<span>TOTAL: {{total_amount}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tu tabla -->
|
||||
<table class="table-header">
|
||||
<thead>
|
||||
<tr class="text-left">
|
||||
<th class="py-2">Concepto</th>
|
||||
<th class="py-2">Cantidad</th>
|
||||
<th class="py-2">Precio unidad</th>
|
||||
<th class="py-2">Importe total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{{#each items}}
|
||||
<tr>
|
||||
<td>{{description}}</td>
|
||||
<td class="text-right">{{#if quantity}}{{quantity}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if unit_amount}}{{unit_amount}}{{else}} {{/if}}</td>
|
||||
<td class="text-right">{{#if taxable_amount}}{{taxable_amount}}{{else}} {{/if}}</td>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="resume" class="flex items-center justify-between pb-4 mb-4">
|
||||
|
||||
<div class="grow relative pt-10 self-start">
|
||||
{{#if payment_method}}
|
||||
<div class="">
|
||||
<p class=" text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty payment method-->
|
||||
{{/if}}
|
||||
{{#if notes}}
|
||||
<div class="pt-4">
|
||||
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- Empty notes-->
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="relative pt-10 grow">
|
||||
<table class=" table-header min-w-full bg-transparent">
|
||||
<tbody>
|
||||
{{#if discount_percentage}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Importe neto</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{subtotal_amount}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="px-4 text-right">Descuento {{discount_percentage}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{discount_amount.value}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<!-- dto 0-->
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">Base imponible</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxable_amount}}</td>
|
||||
</tr>
|
||||
{{#each taxes}}
|
||||
<tr>
|
||||
<td class="px-4 text-right">{{tax_name}}</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right">{{taxes_amount}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
<tr class="">
|
||||
<td class="px-4 text-right accent-color">
|
||||
Total factura
|
||||
</td>
|
||||
<td class="w-5"> </td>
|
||||
<td class="px-4 text-right accent-color">
|
||||
{{total_amount}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
<footer id="footer" class="mt-4">
|
||||
<aside>
|
||||
<p class="text-center">Insc. en el Reg. Merc. de Madrid, Tomo 20.073, Libro 0, Folio 141, Sección 8, Hoja M-354212
|
||||
| CIF: B83999441 -
|
||||
Rodax Software S.L.</p>
|
||||
</aside>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,6 +1,12 @@
|
||||
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import { CreateCustomerRequestDTO } from "../../../../common/dto";
|
||||
import { CreateCustomerUseCase } from "../../../application";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { CreateCustomerRequestDTO } from "../../../../common/dto";
|
||||
import type { CreateCustomerUseCase } from "../../../application";
|
||||
import { customersApiErrorMapper } from "../customer-api-error-mapper";
|
||||
|
||||
export class CreateCustomerController extends ExpressController {
|
||||
@ -9,7 +15,11 @@ export class CreateCustomerController extends ExpressController {
|
||||
this.errorMapper = customersApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import { DeleteCustomerUseCase } from "../../../application";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { DeleteCustomerUseCase } from "../../../application";
|
||||
import { customersApiErrorMapper } from "../customer-api-error-mapper";
|
||||
|
||||
export class DeleteCustomerController extends ExpressController {
|
||||
@ -8,7 +14,11 @@ export class DeleteCustomerController extends ExpressController {
|
||||
this.errorMapper = customersApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
async executeImpl(): Promise<any> {
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import { GetCustomerUseCase } from "../../../application";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { GetCustomerUseCase } from "../../../application";
|
||||
import { customersApiErrorMapper } from "../customer-api-error-mapper";
|
||||
|
||||
export class GetCustomerController extends ExpressController {
|
||||
@ -8,7 +14,11 @@ export class GetCustomerController extends ExpressController {
|
||||
this.errorMapper = customersApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
import { Criteria } from "@repo/rdx-criteria/server";
|
||||
import { ListCustomersUseCase } from "../../../application";
|
||||
|
||||
import type { ListCustomersUseCase } from "../../../application";
|
||||
import { customersApiErrorMapper } from "../customer-api-error-mapper";
|
||||
|
||||
export class ListCustomersController extends ExpressController {
|
||||
@ -9,7 +15,11 @@ export class ListCustomersController extends ExpressController {
|
||||
this.errorMapper = customersApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
private getCriteriaWithDefaultOrder() {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import { authGuard, ExpressController, forbidQueryFieldGuard, tenantGuard } from "@erp/core/api";
|
||||
import { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
|
||||
import { UpdateCustomerUseCase } from "../../../application";
|
||||
import {
|
||||
ExpressController,
|
||||
forbidQueryFieldGuard,
|
||||
requireAuthenticatedGuard,
|
||||
requireCompanyContextGuard,
|
||||
} from "@erp/core/api";
|
||||
|
||||
import type { UpdateCustomerByIdRequestDTO } from "../../../../common/dto";
|
||||
import type { UpdateCustomerUseCase } from "../../../application";
|
||||
import { customersApiErrorMapper } from "../customer-api-error-mapper";
|
||||
|
||||
export class UpdateCustomerController extends ExpressController {
|
||||
@ -9,7 +15,11 @@ export class UpdateCustomerController extends ExpressController {
|
||||
this.errorMapper = customersApiErrorMapper;
|
||||
|
||||
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
|
||||
this.registerGuards(authGuard(), tenantGuard(), forbidQueryFieldGuard("companyId"));
|
||||
this.registerGuards(
|
||||
requireAuthenticatedGuard(),
|
||||
requireCompanyContextGuard(),
|
||||
forbidQueryFieldGuard("companyId")
|
||||
);
|
||||
}
|
||||
|
||||
protected async executeImpl() {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import { type RequestWithAuth, enforceTenant, enforceUser, mockUser } from "@erp/auth/api";
|
||||
import {
|
||||
type RequestWithAuth,
|
||||
mockUser,
|
||||
requireAuthenticated,
|
||||
requireCompanyContext,
|
||||
} from "@erp/auth/api";
|
||||
import { type ModuleParams, validateRequest } from "@erp/core/api";
|
||||
import type { ILogger } from "@repo/rdx-logger";
|
||||
import { type Application, type NextFunction, type Request, type Response, Router } from "express";
|
||||
@ -43,10 +48,10 @@ export const customersRouter = (params: ModuleParams) => {
|
||||
//router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/);
|
||||
router.use([
|
||||
(req: Request, res: Response, next: NextFunction) =>
|
||||
enforceUser()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
requireAuthenticated()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) =>
|
||||
enforceTenant()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
requireCompanyContext()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
||||
]);
|
||||
|
||||
// ----------------------------------------------
|
||||
|
||||
@ -12,8 +12,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>1.1</Version>
|
||||
<AssemblyVersion>1.1.0.8</AssemblyVersion>
|
||||
<FileVersion>1.1.0.8</FileVersion>
|
||||
<AssemblyVersion>1.1.0.9</AssemblyVersion>
|
||||
<FileVersion>1.1.0.9</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -104,6 +104,8 @@ class Program
|
||||
Console.WriteLine($"Generated HTML: {outputPath}");
|
||||
}
|
||||
|
||||
report.Dispose();
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user