feat: agregar soporte para múltiples idiomas en el informe de presupuesto

This commit is contained in:
David Arranz 2026-10-05 14:31:17 +02:00
parent c77a3a3af5
commit e0f52da54f
4 changed files with 112 additions and 26 deletions

View File

@ -0,0 +1,57 @@
export const reportLabels = {
es: {
official_distributor: "DISTRIBUIDOR OFICIAL",
dealer_logo: "Logo distribuidor",
document_title: "PRESUPUESTO",
browser_title: "Presupuesto",
quote_number: "Presupuesto nº:",
date: "Fecha:",
validity: "Validez:",
salesperson: "Vendedor:",
customer_reference: "Referencia cliente:",
page: "Página",
of: "de",
quantity: "Cant.",
description: "Descripción",
unit_price: "Prec. Unitario",
subtotal: "Subtotal",
discount: "Dto (%)",
total_amount: "Importe total",
payment_method: "Forma de pago:",
notes: "Notas:",
net_amount: "Importe neto",
discounts: "Descuentos",
percentage_discount: "% Descuento",
taxable_amount: "Base imponible",
},
en: {
official_distributor: "OFFICIAL DISTRIBUTOR",
dealer_logo: "Dealer logo",
document_title: "QUOTE",
browser_title: "Quote",
quote_number: "Quote no.:",
date: "Date:",
validity: "Validity:",
salesperson: "Salesperson:",
customer_reference: "Customer reference:",
page: "Page",
of: "of",
quantity: "Qty.",
description: "Description",
unit_price: "Unit price",
subtotal: "Subtotal",
discount: "Discount (%)",
total_amount: "Total amount",
payment_method: "Payment method:",
notes: "Notes:",
net_amount: "Net amount",
discounts: "Discounts",
percentage_discount: "% Discount",
taxable_amount: "Taxable amount",
},
} as const;
export type ReportLanguage = keyof typeof reportLabels;
export const resolveReportLanguage = (language: string): ReportLanguage =>
language === "en" ? "en" : "es";

View File

@ -2,7 +2,12 @@ import { ISalesContext } from "@/contexts/sales/infrastructure/Sales.context";
import { createQuoteMapper } from "@/contexts/sales/infrastructure/mappers/quote.mapper";
import { ReportQuotePresenter } from "./ReportQuote.reporter";
const source = (showLineDiscountColumn: boolean, generalDiscount: number, taxLabel = "% IVA") =>
const source = (
showLineDiscountColumn: boolean,
generalDiscount: number,
taxLabel = "% IVA",
langCode = "es"
) =>
({
show_line_discount_column: showLineDiscountColumn,
items: [],
@ -12,7 +17,7 @@ const source = (showLineDiscountColumn: boolean, generalDiscount: number, taxLab
date: "2026-10-03",
reference: "Q-1",
currency_code: "EUR",
lang_code: "es",
lang_code: langCode,
customer_reference: "",
customer_information: "Customer",
validity: "",
@ -51,4 +56,25 @@ describe("ReportQuotePresenter", () => {
expect(html).toContain("% IGIC");
});
it("renders English report labels while preserving quote item snapshots", async () => {
const html = await ReportQuotePresenter.toHTML(
mapper.mapToDomain(source(false, 0, "% IVA", "en")),
context
);
expect(html).toContain('<html lang="en">');
expect(html).toContain("QUOTE");
expect(html).toContain("Description");
expect(html).not.toContain(">PRESUPUESTO<");
});
it("falls back to Spanish labels and language for unsupported quote languages", async () => {
const html = await ReportQuotePresenter.toHTML(mapper.mapToDomain(source(false, 0, "% IGIC", "fr")), context);
expect(html).toContain('<html lang="es">');
expect(html).toContain("PRESUPUESTO");
expect(html).toContain("Descripción");
expect(html).toContain("% IGIC");
});
});

View File

@ -7,6 +7,7 @@ import * as handlebars from "handlebars";
import path from "path";
import puppeteer from "puppeteer";
import report from "puppeteer-report";
import { reportLabels, resolveReportLanguage } from "./ReportQuote.labels";
export interface IReportQuoteReporter {
toHTML: (quote: Quote, context: ISalesContext) => Promise<string>;
@ -63,6 +64,7 @@ export const ReportQuotePresenter: IReportQuoteReporter = {
const map = async (quote: Quote, context: ISalesContext) => {
const { dealer } = context;
const langCode = resolveReportLanguage(quote.language.toString());
return {
id: quote.id.toString(),
@ -71,7 +73,8 @@ const map = async (quote: Quote, context: ISalesContext) => {
reference: quote.reference.toString(),
customer_reference: quote.customerReference.toString(),
customer_information: quote.customer.toString(),
lang_code: quote.language.toString(),
lang_code: langCode,
labels: reportLabels[langCode],
currency_code: quote.currency.toString(),
payment_method: quote.paymentMethod.toString(),

View File

@ -4,7 +4,7 @@
<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>
<title>{{labels.browser_title}} #{{id}}</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
@ -36,7 +36,7 @@
<header id="header">
<aside class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">DISTRIBUIDOR OFICIAL</h3>
<h3 class="text-lg font-semibold">{{labels.official_distributor}}</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
@ -46,25 +46,25 @@
</div>
</aside>
<section class="flex pb-2 space-x-4">
<img id="dealer-logo" src={{dealer.logo}} alt="Logo distribuidor" />
<img id="dealer-logo" src={{dealer.logo}} alt="{{labels.dealer_logo}}" />
<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>
<p class="text-sm"><strong>{{labels.quote_number}}</strong> {{reference}}</p>
<p class="text-sm"><strong>{{labels.date}}</strong> {{date}}</p>
<p class="text-sm"><strong>{{labels.validity}}</strong> {{validity}}</p>
<p class="text-sm"><strong>{{labels.salesperson}}</strong> {{dealer.name}}</p>
<p class="text-sm"><strong>{{labels.customer_reference}}</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>
<h3 class="text-2xl font-normal">{{labels.document_title}}</h3>
<div id="header-pagination">
Página <span class="pageNumber"></span> de <span class="totalPages"></span>
{{labels.page}} <span class="pageNumber"></span> {{labels.of}} <span class="totalPages"></span>
</div>
</aside>
</header>
@ -73,14 +73,14 @@
<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">{{labels.quantity}}</th>
<th class="px-2 py-2 border">{{labels.description}}</th>
<th class="px-2 py-2 text-right border">{{labels.unit_price}}</th>
<th class="px-2 py-2 text-right border">{{labels.subtotal}}</th>
{{#if show_line_discount_column}}
<th class="px-2 py-2 text-right border">Dto (%)</th>
<th class="px-2 py-2 text-right border">{{labels.discount}}</th>
{{/if}}
<th class="px-2 py-2 text-right border">Importe total</th>
<th class="px-2 py-2 text-right border">{{labels.total_amount}}</th>
</tr>
</thead>
<tbody class="table-body">
@ -104,10 +104,10 @@
<div class="grow">
<div class="pt-4">
<p class="text-sm"><strong>Forma de pago:</strong> {{payment_method}}</p>
<p class="text-sm"><strong>{{labels.payment_method}}</strong> {{payment_method}}</p>
</div>
<div class="pt-4">
<p class="text-sm"><strong>Notas:</strong> {{notes}} </p>
<p class="text-sm"><strong>{{labels.notes}}</strong> {{notes}} </p>
</div>
</div>
@ -115,26 +115,26 @@
<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">{{labels.net_amount}}</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{subtotal_price}}</td>
</tr>
{{#if show_line_discount_column}}
<tr class="border-b">
<td class="px-4 py-2">Descuentos</td>
<td class="px-4 py-2">{{labels.discounts}}</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{line_discount_price}}</td>
</tr>
{{/if}}
{{#if has_general_discount}}
<tr class="border-b">
<td class="px-4 py-2">% Descuento</td>
<td class="px-4 py-2">{{labels.percentage_discount}}</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>
{{/if}}
<tr class="border-b">
<td class="px-4 py-2">Base imponible</td>
<td class="px-4 py-2">{{labels.taxable_amount}}</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{before_tax_price}}</td>
</tr>
@ -144,7 +144,7 @@
<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">{{labels.total_amount}}</td>
<td class="px-4 py-2"></td>
<td class="px-4 py-2 text-right border">{{total_price}}</td>
</tr>