diff --git a/README.md b/README.md index 5b69db9..3338567 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ El sistema está construido como un **monolito modular** con límites de dominio Discovery → Lead → Opportunity → Quote → Order → Inventory → Portal → Invoice → Payment ``` -**957 tests** (contrato + integración + e2e) en 49 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). +**974 tests** (contrato + integración + e2e) en 50 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). --- @@ -106,7 +106,7 @@ cbos-platform/ │ │ │ ├── accounting/ Facturación, pagos, overdue scanner │ │ │ └── analytics/ Agregación cross-módulo │ │ └── main.py Lifespan, routers, CORS, background tasks -│ ├── tests/ 957 tests (pytest-asyncio) +│ ├── tests/ 974 tests (pytest-asyncio) │ ├── alembic/ Migraciones de base de datos │ └── requirements.txt ├── composable-os/ React 18 + Vite frontend @@ -232,7 +232,7 @@ docker compose exec backend pytest --tb=short -q cd backend && pytest --tb=short -q ``` -**957 tests** en 49 archivos. Las categorías no se solapan: cada archivo cuenta +**974 tests** en 50 archivos. Las categorías no se solapan: cada archivo cuenta una sola vez y las filas suman 41. | Categoría | Archivos | Cobertura | diff --git a/backend/app/core/i18n/format.py b/backend/app/core/i18n/format.py index 712800b..8737fd3 100644 --- a/backend/app/core/i18n/format.py +++ b/backend/app/core/i18n/format.py @@ -60,10 +60,18 @@ def _rule_for(locale: str | None) -> _Rule: return _RULES[default] -def format_number(value: float, locale: str | None = DEFAULT_LOCALE, *, decimals: int = 2) -> str: - """`1160` -> `1,160.00` en `es`/`en`, con los separadores de la region.""" +def format_number( + value: float, locale: str | None = DEFAULT_LOCALE, *, decimals: int | None = 2 +) -> str: + """`1160` -> `1,160.00` en `es`/`en`, con los separadores de la region. + + `decimals=None` no fija decimales: `10` -> `10`, `2.5` -> `2.5` (una cantidad). + """ rule = _rule_for(locale) - text = f"{value:,.{decimals}f}" + if decimals is None: + text = f"{value:,.6f}".rstrip("0").rstrip(".") + else: + text = f"{value:,.{decimals}f}" integer_digits = len(text.split(".")[0].lstrip("-").replace(",", "")) if integer_digits < rule.min_grouping + 3: text = text.replace(",", "") diff --git a/backend/app/core/i18n/locales/en/quote_pdf.json b/backend/app/core/i18n/locales/en/quote_pdf.json new file mode 100644 index 0000000..4798f0c --- /dev/null +++ b/backend/app/core/i18n/locales/en/quote_pdf.json @@ -0,0 +1,38 @@ +{ + "title": "QUOTE", + "footer": "Page {number}", + "meta": { + "number": "Number", + "title": "Title", + "status": "Status", + "validUntil": "Valid until", + "currency": "Currency" + }, + "customer": { + "label": "CUSTOMER", + "attention": "Attn: {name}" + }, + "items": "LINE ITEMS", + "table": { + "description": "Description", + "quantity": "Qty.", + "unitPrice": "Unit price", + "discount": "Disc.%", + "amount": "Amount" + }, + "totals": { + "subtotal": "Subtotal", + "discount": "Discount", + "tax": "Tax ({rate}%)", + "total": "TOTAL" + }, + "notes": "NOTES", + "terms": "TERMS AND CONDITIONS", + "status": { + "draft": "Draft", + "sent": "Sent", + "accepted": "Accepted", + "rejected": "Rejected", + "expired": "Expired" + } +} diff --git a/backend/app/core/i18n/locales/es/quote_pdf.json b/backend/app/core/i18n/locales/es/quote_pdf.json new file mode 100644 index 0000000..852366c --- /dev/null +++ b/backend/app/core/i18n/locales/es/quote_pdf.json @@ -0,0 +1,38 @@ +{ + "title": "COTIZACIÓN", + "footer": "Página {number}", + "meta": { + "number": "Número", + "title": "Título", + "status": "Estado", + "validUntil": "Válida hasta", + "currency": "Moneda" + }, + "customer": { + "label": "CLIENTE", + "attention": "Attn: {name}" + }, + "items": "DETALLE DE SERVICIOS / PRODUCTOS", + "table": { + "description": "Descripción", + "quantity": "Cant.", + "unitPrice": "Precio Unit.", + "discount": "Desc.%", + "amount": "Importe" + }, + "totals": { + "subtotal": "Subtotal", + "discount": "Descuento", + "tax": "Impuesto ({rate}%)", + "total": "TOTAL" + }, + "notes": "NOTAS", + "terms": "TÉRMINOS Y CONDICIONES", + "status": { + "draft": "Borrador", + "sent": "Enviada", + "accepted": "Aceptada", + "rejected": "Rechazada", + "expired": "Vencida" + } +} diff --git a/backend/app/modules/sales/pdf.py b/backend/app/modules/sales/pdf.py index 8fe42b5..b676329 100644 --- a/backend/app/modules/sales/pdf.py +++ b/backend/app/modules/sales/pdf.py @@ -1,19 +1,35 @@ -"""PDF generation for quotes using fpdf2.""" +"""PDF generation for quotes using fpdf2. + +Texto en el idioma pedido (catalogo `quote_pdf`) y fechas e importes con el formato del +idioma (`core/i18n/format.py`). El idioma lo resuelve quien llama con +`app.core.deps.resolve_user_locale`; aqui solo se usa. +""" from datetime import date from io import BytesIO from fpdf import FPDF, XPos, YPos +from app.core.i18n import DEFAULT_LOCALE +from app.core.i18n.catalogue import exists, translate +from app.core.i18n.format import format_date, format_money, format_number from app.modules.sales.models import Quote +def _status_label(status: str, locale: str) -> str: + # Un estado nuevo en el backend no debe romper el PDF: sin entrada en el catalogo + # sale el valor tal cual, como en el PDF de factura. + key = f"quote_pdf:status.{status}" + return translate(key, locale) if exists(key, locale) else status + + class QuotePDF(FPDF): """PDF renderer for a Quote.""" - def __init__(self, workspace_name: str, *args, **kwargs): + def __init__(self, workspace_name: str, *args, locale: str = DEFAULT_LOCALE, **kwargs): super().__init__(*args, **kwargs) self.workspace_name = workspace_name + self._locale = locale self.set_margins(15, 15, 15) self.set_auto_page_break(auto=True, margin=20) @@ -30,7 +46,7 @@ def footer(self): self.set_y(-15) self.set_font("Helvetica", "I", 8) self.set_text_color(150, 150, 150) - self.cell(0, 10, f"Página {self.page_no()}", align="C") + self.cell(0, 10, translate("quote_pdf:footer", self._locale, number=self.page_no()), align="C") def _section_title(self, text: str): self.set_font("Helvetica", "B", 10) @@ -47,7 +63,7 @@ def _kv(self, label: str, value: str, w_label: int = 40): self.cell(0, 6, value, new_x=XPos.LMARGIN, new_y=YPos.NEXT) def _money(self, value: float, currency: str) -> str: - return f"{currency} {value:,.2f}" + return format_money(value, currency, self._locale) def generate_quote_pdf( @@ -55,16 +71,17 @@ def generate_quote_pdf( workspace_name: str, contact_name: str | None = None, org_name: str | None = None, + locale: str = DEFAULT_LOCALE, ) -> bytes: """Generate a professional PDF for the given quote. Returns raw bytes.""" - pdf = QuotePDF(workspace_name=workspace_name) + pdf = QuotePDF(workspace_name=workspace_name, locale=locale) pdf.add_page() # ── Quote header info ──────────────────────────────────────────────────── pdf.set_font("Helvetica", "B", 16) pdf.set_text_color(0, 0, 0) - pdf.cell(0, 10, "COTIZACIÓN", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + pdf.cell(0, 10, translate("quote_pdf:title", locale), new_x=XPos.LMARGIN, new_y=YPos.NEXT) pdf.ln(2) # Two-column: quote meta left, client info right @@ -73,18 +90,18 @@ def generate_quote_pdf( # Left column — quote metadata pdf.set_xy(x_left, y_start) - pdf._kv("Número", quote.quote_number) - pdf._kv("Título", quote.title) - pdf._kv("Estado", quote.status.upper()) + pdf._kv(translate("quote_pdf:meta.number", locale), quote.quote_number) + pdf._kv(translate("quote_pdf:meta.title", locale), quote.title) + pdf._kv(translate("quote_pdf:meta.status", locale), _status_label(quote.status, locale).upper()) if quote.valid_until: - pdf._kv("Válida hasta", quote.valid_until.strftime("%d/%m/%Y")) - pdf._kv("Moneda", quote.currency) + pdf._kv(translate("quote_pdf:meta.validUntil", locale), format_date(quote.valid_until, locale)) + pdf._kv(translate("quote_pdf:meta.currency", locale), quote.currency) # Right column — client info y_client = y_start pdf.set_xy(110, y_client) pdf.set_font("Helvetica", "B", 9) - pdf.cell(0, 6, "CLIENTE", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + pdf.cell(0, 6, translate("quote_pdf:customer.label", locale), new_x=XPos.LMARGIN, new_y=YPos.NEXT) pdf.set_xy(110, pdf.get_y()) if org_name: @@ -93,7 +110,8 @@ def generate_quote_pdf( pdf.set_xy(110, pdf.get_y()) if contact_name: pdf.set_font("Helvetica", "I", 9) - pdf.cell(0, 6, f"Attn: {contact_name}", new_x=XPos.LMARGIN, new_y=YPos.NEXT) + attention = translate("quote_pdf:customer.attention", locale, name=contact_name) + pdf.cell(0, 6, attention, new_x=XPos.LMARGIN, new_y=YPos.NEXT) pdf.set_xy(110, pdf.get_y()) # Move below both columns @@ -101,11 +119,17 @@ def generate_quote_pdf( pdf.ln(6) # ── Line items table ───────────────────────────────────────────────────── - pdf._section_title("DETALLE DE SERVICIOS / PRODUCTOS") + pdf._section_title(translate("quote_pdf:items", locale)) # Table header col_w = [90, 20, 30, 20, 25] # desc, qty, unit, disc%, amount - headers = ["Descripción", "Cant.", "Precio Unit.", "Desc.%", "Importe"] + headers = [ + translate("quote_pdf:table.description", locale), + translate("quote_pdf:table.quantity", locale), + translate("quote_pdf:table.unitPrice", locale), + translate("quote_pdf:table.discount", locale), + translate("quote_pdf:table.amount", locale), + ] pdf.set_font("Helvetica", "B", 9) pdf.set_fill_color(30, 64, 175) @@ -129,10 +153,10 @@ def generate_quote_pdf( row_h = pdf.get_y() - y_before pdf.set_xy(x_before + col_w[0], y_before) - pdf.cell(col_w[1], row_h, f"{line.quantity:g}", border="LR", align="C", fill=fill) - pdf.cell(col_w[2], row_h, f"{quote.currency} {line.unit_price:,.2f}", border="LR", align="R", fill=fill) + pdf.cell(col_w[1], row_h, format_number(line.quantity, locale, decimals=None), border="LR", align="C", fill=fill) + pdf.cell(col_w[2], row_h, pdf._money(line.unit_price, quote.currency), border="LR", align="R", fill=fill) pdf.cell(col_w[3], row_h, f"{line.discount_percent:.0f}%", border="LR", align="C", fill=fill) - pdf.cell(col_w[4], row_h, f"{line.amount:,.2f}", border="LR", align="R", fill=fill) + pdf.cell(col_w[4], row_h, format_number(line.amount, locale), border="LR", align="R", fill=fill) pdf.ln(row_h) fill = not fill @@ -153,33 +177,39 @@ def total_row(label: str, value: str, bold: bool = False): pdf.cell(40, 7, label, align="L") pdf.cell(25, 7, value, align="R", new_x=XPos.LMARGIN, new_y=YPos.NEXT) - total_row("Subtotal:", f"{quote.currency} {quote.subtotal:,.2f}") + total_row(translate("quote_pdf:totals.subtotal", locale) + ":", pdf._money(quote.subtotal, quote.currency)) if quote.discount_amount > 0: - total_row("Descuento:", f"-{quote.currency} {quote.discount_amount:,.2f}") + total_row( + translate("quote_pdf:totals.discount", locale) + ":", + "-" + pdf._money(quote.discount_amount, quote.currency), + ) if quote.tax_rate > 0: - total_row(f"Impuesto ({quote.tax_rate:.0f}%):", f"{quote.currency} {quote.tax_amount:,.2f}") + total_row( + translate("quote_pdf:totals.tax", locale, rate=f"{quote.tax_rate:.0f}") + ":", + pdf._money(quote.tax_amount, quote.currency), + ) # Total line with background pdf.set_fill_color(30, 64, 175) pdf.set_text_color(255, 255, 255) pdf.set_font("Helvetica", "B", 11) pdf.set_x(total_x) - pdf.cell(40, 9, "TOTAL:", fill=True, align="L") - pdf.cell(25, 9, f"{quote.currency} {quote.total:,.2f}", fill=True, align="R", + pdf.cell(40, 9, translate("quote_pdf:totals.total", locale) + ":", fill=True, align="L") + pdf.cell(25, 9, pdf._money(quote.total, quote.currency), fill=True, align="R", new_x=XPos.LMARGIN, new_y=YPos.NEXT) pdf.set_text_color(0, 0, 0) pdf.ln(8) # ── Notes ───────────────────────────────────────────────────────────────── if quote.notes: - pdf._section_title("NOTAS") + pdf._section_title(translate("quote_pdf:notes", locale)) pdf.set_font("Helvetica", "", 9) pdf.multi_cell(0, 6, quote.notes) pdf.ln(4) # ── Terms ───────────────────────────────────────────────────────────────── if quote.terms: - pdf._section_title("TÉRMINOS Y CONDICIONES") + pdf._section_title(translate("quote_pdf:terms", locale)) pdf.set_font("Helvetica", "", 8) pdf.set_text_color(80, 80, 80) pdf.multi_cell(0, 5, quote.terms) diff --git a/backend/app/modules/sales/router.py b/backend/app/modules/sales/router.py index 37b1643..5b3a069 100644 --- a/backend/app/modules/sales/router.py +++ b/backend/app/modules/sales/router.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.core.deps import get_current_user, get_current_workspace_id +from app.core.deps import get_current_locale, get_current_user, get_current_workspace_id from app.modules.identity.models import User from app.modules.sales import service @@ -177,8 +177,9 @@ async def download_quote_pdf( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), workspace_id: str = Depends(get_current_workspace_id), + locale: str = Depends(get_current_locale), ): - """Returns the quote as a downloadable PDF.""" + """Returns the quote as a downloadable PDF, in the requester's language.""" quote, contact_name, org_name = await service.get_quote_pdf_data( db, workspace_id, quote_id ) @@ -191,7 +192,7 @@ async def download_quote_pdf( ws = result.scalar_one_or_none() workspace_name = ws.name if ws else workspace_id - pdf_bytes = generate_quote_pdf(quote, workspace_name, contact_name, org_name) + pdf_bytes = generate_quote_pdf(quote, workspace_name, contact_name, org_name, locale=locale) filename = f"{quote.quote_number}.pdf" return Response( diff --git a/backend/tests/data/quote_pdf_es_golden.txt b/backend/tests/data/quote_pdf_es_golden.txt new file mode 100644 index 0000000..79331f8 --- /dev/null +++ b/backend/tests/data/quote_pdf_es_golden.txt @@ -0,0 +1,23 @@ +Acme SA +COTIZACIÓN +Número: Q-2026-0001 +Título: Implementacion CRM +Estado: ENVIADA +Válida hasta: 01/09/2026 +Moneda: USD +CLIENTE +Comercial Norte SA +Attn: Ana +DETALLE DE SERVICIOS / PRODUCTOS +Descripción Cant. Precio Unit. Desc.% Importe +Consultoria 10 USD 100.00 0% 1,000.00 +Licencia 2.5 USD 250.00 20% 500.00 +Subtotal: USD 1,500.00 +Descuento: -USD 50.00 +Impuesto (16%): USD 232.00 +TOTAL: USD 1,682.00 +NOTAS +Gracias por su preferencia +TÉRMINOS Y CONDICIONES +Pago a 30 dias +Página 1 \ No newline at end of file diff --git a/backend/tests/test_english_artifacts.py b/backend/tests/test_english_artifacts.py index 27c451b..3af93f9 100644 --- a/backend/tests/test_english_artifacts.py +++ b/backend/tests/test_english_artifacts.py @@ -15,8 +15,8 @@ palabras menos las del `en`) no aparece en lo que sale en `en`. Un texto que se quedo en espanol tiene palabras que ningun catalogo ingles contiene. -Lo que NO cubre: fechas e importes siguen sin seguir al idioma (ver tarea 10). El -idioma ya se ofrece (`SUPPORTED_LOCALES`), pero estos tests pasan `locale="en"` a +Cubre tambien el PDF de cotizacion (al final). Fechas e importes tienen sus propios +tests (`test_i18n_format.py`). El idioma ya se ofrece (`SUPPORTED_LOCALES`), pero estos tests pasan `locale="en"` a mano: no ejercitan la cadena de resolucion, que cubren `test_locale.py` y `test_locale_resolution.py`. """ @@ -29,6 +29,7 @@ from tests.test_email_templates import CASES, render from tests.test_invoice_pdf_locale import STATUSES, _full_invoice, _party, _profile, _with_status from tests.test_invoice_pdf_rendering import _text +from tests.test_quote_pdf_locale import _pdf as _quote_pdf pytestmark = pytest.mark.asyncio @@ -57,7 +58,7 @@ def _vocabulary(domains: dict) -> set[str]: # Palabras que los dos catalogos comparten de verdad (cognados, siglas, marca). Es una # lista fija a proposito: restar el vocabulario del catalogo `en` que se esta probando # seria circular, y una cadena copiada del espanol entraria en el y se daria por buena. -SHARED_WITH_SPANISH = {"cbos", "error", "platform", "sku", "stock", "subtotal", "total", "unit", "workflow"} +SHARED_WITH_SPANISH = {"attn", "cbos", "error", "platform", "sku", "stock", "subtotal", "total", "unit", "workflow"} def spanish_only_words() -> set[str]: @@ -193,3 +194,30 @@ async def test_every_invoice_status_has_an_english_label(status, spanish): text = _text(generate_invoice_pdf(_with_status(status), locale="en")) assert english in text assert spanish not in text + + +# ── PDF de cotizacion ──────────────────────────────────────────────────────── + +# Datos de la cotizacion de prueba: texto de una persona, que ningun catalogo traduce. +_QUOTE_DATA = ("Q-2026-0001", "Implementacion CRM", "Acme SA", "Comercial Norte SA", "Consultoria", + "Licencia", "Gracias por su preferencia", "Pago a 30 dias") + + +async def test_the_quote_pdf_labels_are_english(): + text = _quote_pdf(locale="en") + for label in ("QUOTE", "Number", "Title", "Status", "Valid until", "Currency", "CUSTOMER", + "LINE ITEMS", "Description", "Qty.", "Unit price", "Amount", "Subtotal", + "Discount", "Tax (16%)", "TOTAL", "NOTES", "TERMS AND CONDITIONS", "Page 1"): + assert label in text, label + + +async def test_the_quote_pdf_has_no_spanish_left_in_english(): + data = words_in(" ".join(_QUOTE_DATA)) + leaked = {w for w in words_in(_quote_pdf(locale="en")) & spanish_only_words() if w not in data} + assert leaked == set() + + +async def test_the_check_detects_spanish_left_in_the_quote_pdf(): + # El mismo PDF en espanol debe dar palabras que solo existen en espanol. + leaked = words_in(_quote_pdf(locale="es")) & spanish_only_words() + assert {"cotización", "cliente", "notas"} <= leaked diff --git a/backend/tests/test_quote_pdf_locale.py b/backend/tests/test_quote_pdf_locale.py new file mode 100644 index 0000000..05a5cab --- /dev/null +++ b/backend/tests/test_quote_pdf_locale.py @@ -0,0 +1,237 @@ +""" +El PDF de cotizacion en el idioma pedido (tarea 12 del plan de i18n). + +Mismas garantias que el PDF de factura (`test_invoice_pdf_locale.py`): + +1. **En espanol no cambia nada, salvo una cosa corregida.** `data/quote_pdf_es_golden.txt` + es el texto del PDF tal y como salia *antes* de pasar a catalogo. La unica diferencia: + el estado salia como el codigo en ingles en mayusculas (`SENT`) y ahora sale + traducido (`ENVIADA`). El golden lleva ese cambio hecho a mano. +2. **El idioma llega a todo el texto.** Con el espanol solo no se puede observar: un + texto cableado se ve igual que uno que ignora el parametro. Los casos de segundo + idioma inyectan un catalogo pseudo-localizado (`EN()`). + +La ruta toma el idioma de `get_current_locale`, como la de factura. +""" +from datetime import date +from pathlib import Path + +import pytest +from httpx import AsyncClient +from sqlalchemy import update + +from app.core.i18n import catalogue +from app.modules.identity.models import User, Workspace +from app.modules.sales.models import Quote, QuoteLine +from app.modules.sales.pdf import generate_quote_pdf +from tests.test_invoice_pdf_rendering import _text + +pytestmark = pytest.mark.asyncio + +GOLDEN = (Path(__file__).parent / "data" / "quote_pdf_es_golden.txt").read_text(encoding="utf-8") + +STATUSES = { + "draft": "Borrador", + "sent": "Enviada", + "accepted": "Aceptada", + "rejected": "Rechazada", + "expired": "Vencida", +} + + +def _quote(status: str = "sent") -> Quote: + q = Quote( + workspace_id="ws-1", quote_number="Q-2026-0001", title="Implementacion CRM", + status=status, valid_until=date(2026, 9, 1), currency="USD", + subtotal=1500.0, discount_amount=50.0, tax_rate=16.0, tax_amount=232.0, total=1682.0, + notes="Gracias por su preferencia", terms="Pago a 30 dias", + ) + q.lines = [ + QuoteLine(workspace_id="ws-1", line_order=1, description="Consultoria", quantity=10, + unit_price=100.0, discount_percent=0.0, amount=1000.0), + QuoteLine(workspace_id="ws-1", line_order=2, description="Licencia", quantity=2.5, + unit_price=250.0, discount_percent=20.0, amount=500.0), + ] + return q + + +def _pdf(quote: Quote | None = None, locale: str | None = None) -> str: + kwargs = {} if locale is None else {"locale": locale} + return _text(generate_quote_pdf(quote or _quote(), "Acme SA", "Ana", "Comercial Norte SA", **kwargs)) + + +def _pseudo(tree: dict) -> dict: + return {k: _pseudo(v) if isinstance(v, dict) else f"EN({v})" for k, v in tree.items()} + + +def _leaves(tree: dict): + for value in tree.values(): + if isinstance(value, dict): + yield from _leaves(value) + else: + yield value + + +@pytest.fixture +def with_pseudo_english(monkeypatch): + real = catalogue.load_catalogues() + monkeypatch.setattr( + catalogue, + "load_catalogues", + lambda: {**real, "en": {d: _pseudo(t) for d, t in real["es"].items()}}, + ) + + +# ── En espanol, nada cambia ────────────────────────────────────────────────── + + +async def test_spanish_output_is_what_the_pdf_always_said(): + assert _pdf() == GOLDEN.rstrip("\n") + + +async def test_the_spanish_locales_render_the_same(): + assert _pdf(locale="es") == _pdf(locale="es-MX") == _pdf() == GOLDEN.rstrip("\n") + + +async def test_a_locale_without_catalogue_renders_in_spanish(): + assert _pdf(locale="xx") == GOLDEN.rstrip("\n") + + +async def test_each_status_is_translated_not_the_internal_code(): + for status, label in STATUSES.items(): + text = _pdf(_quote(status)) + assert f"Estado: {label.upper()}" in text, status + + +async def test_an_unknown_status_comes_out_as_it_is(): + # Como el resto de estados, en mayusculas: lo unico que cambia es que no se traduce. + assert "Estado: ARCHIVED" in _pdf(_quote("archived")) + + +# ── El idioma llega a todo el texto ────────────────────────────────────────── + + +async def test_every_label_is_in_the_second_language(with_pseudo_english): + text = _pdf(locale="en") + es = catalogue.load_catalogues()["es"]["quote_pdf"] + static = [ + m for m in _leaves({k: v for k, v in es.items() if k != "status"}) + if "{" not in m + ] + assert len(static) >= 15 # el control puede fallar: si no hay etiquetas, pasa sin mirar + for message in static: + assert f"EN({message})" in text, message + assert "EN(Attn: Ana)" in text + assert "EN(Impuesto (16%))" in text + assert "EN(Página 1)" in text + + +async def test_the_second_language_leaves_no_spanish_label_behind(with_pseudo_english): + text = _pdf(locale="en") + for spanish in ("COTIZACIÓN", "CLIENTE", "NOTAS", "Descripción", "Importe", "Moneda"): + assert spanish not in text.replace(f"EN({spanish})", ""), spanish + + +async def test_the_status_in_the_second_language(with_pseudo_english): + for status, label in STATUSES.items(): + assert f"EN({label.upper()})" in _pdf(_quote(status), locale="en"), status + + +async def test_quote_data_is_never_translated(with_pseudo_english): + text = _pdf(locale="en") + for data in ("Q-2026-0001", "Implementacion CRM", "Acme SA", "Comercial Norte SA", "Consultoria", + "Licencia", "Gracias por su preferencia", "Pago a 30 dias"): + assert data in text, data + assert f"EN({data})" not in text, data + + +# ── Con el catalogo real en ingles ─────────────────────────────────────────── + + +async def test_real_english_reads_as_english(): + text = _pdf(locale="en") + for expected in ("QUOTE", "Number: Q-2026-0001", "Status: SENT", "Valid until: 09/01/2026", + "CUSTOMER", "Attn: Ana", "LINE ITEMS", "Qty.", "Unit price", "Amount", + "Discount: -USD 50.00", "Tax (16%): USD 232.00", "TOTAL: USD 1,682.00", + "NOTES", "TERMS AND CONDITIONS", "Page 1"): + assert expected in text, expected + + +# ── Fechas e importes siguen al idioma ─────────────────────────────────────── + + +async def test_dates_amounts_and_quantities_follow_the_language(): + cases = [ + ("es", "01/09/2026", "USD 1,500.00", "2.5"), + ("en", "09/01/2026", "USD 1,500.00", "2.5"), + ("en-GB", "01/09/2026", "USD 1,500.00", "2.5"), + ("es-ES", "01/09/2026", "USD 1500,00", "2,5"), + ] + for locale, day, amount, quantity in cases: + text = _pdf(locale=locale) + assert day in text, locale + assert amount in text, locale + assert f"Licencia {quantity} " in text, locale + + +# ── La ruta ────────────────────────────────────────────────────────────────── + +BASE = "/api/v1/sales" + + +async def _create_quote(client: AsyncClient, headers: dict) -> dict: + resp = await client.post(f"{BASE}/quotes", headers=headers, json={ + "title": "Ruta", "currency": "USD", "tax_rate": 0.0, "discount_amount": 0.0, + "lines": [{"description": "Widget", "quantity": 2, "unit_price": 100.0, + "discount_percent": 0.0, "line_order": 1}], + }) + assert resp.status_code == 201, resp.text + return resp.json() + + +@pytest.fixture +def captured(monkeypatch): + calls: list[str] = [] + + def fake(quote, workspace_name, contact_name=None, org_name=None, locale="es"): + calls.append(locale) + return b"%PDF-fake" + + monkeypatch.setattr("app.modules.sales.router.generate_quote_pdf", fake) + return calls + + +async def test_the_route_defaults_to_spanish(client: AsyncClient, auth_headers: dict, captured): + quote = await _create_quote(client, auth_headers) + + await client.get(f"{BASE}/quotes/{quote['id']}/pdf", headers=auth_headers) + + assert captured == ["es"] + + +async def test_the_route_follows_the_users_language( + client: AsyncClient, auth_headers: dict, test_user: User, session_factory, captured +): + async with session_factory() as session: + await session.execute(update(User).where(User.id == test_user.id).values(locale="en-US")) + await session.commit() + quote = await _create_quote(client, auth_headers) + + await client.get(f"{BASE}/quotes/{quote['id']}/pdf", headers=auth_headers) + + assert captured == ["en-US"] + + +async def test_the_route_follows_the_workspace_default( + client: AsyncClient, auth_headers: dict, test_user: User, session_factory, captured +): + async with session_factory() as session: + await session.execute( + update(Workspace).where(Workspace.id == test_user.workspace_id).values(default_locale="en") + ) + await session.commit() + quote = await _create_quote(client, auth_headers) + + await client.get(f"{BASE}/quotes/{quote['id']}/pdf", headers=auth_headers) + + assert captured == ["en"] diff --git a/docs/CAPABILITY_MATURITY_SCORECARD.md b/docs/CAPABILITY_MATURITY_SCORECARD.md index fe7e2e7..92c5ee2 100644 --- a/docs/CAPABILITY_MATURITY_SCORECARD.md +++ b/docs/CAPABILITY_MATURITY_SCORECARD.md @@ -147,4 +147,4 @@ A module may advance from Tier 3 → Tier 2 or Tier 2 → Tier 1 when it achieve **12 modules Tier 1 — 12 total modules.** Full wedge funnel: Discovery → Identity → CRM → Sales → Inventory → Portal → Workflows → Accounting → Notifications → Contracts → Projects → HR. -**957 tests across 49 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. +**974 tests across 50 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index 07c2b36..7e17758 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -58,7 +58,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR ## Current Platform Snapshot - Active modules: identity, crm, sales, inventory, portal, discovery, workflows, notifications, accounting, analytics, contracts, projects, hr -- Test suite: 957 automated tests across 49 test files +- Test suite: 974 automated tests across 50 test files - Frontend active surface: API-backed pages aligned to the implemented backend wedge plus current Tier 1 modules - Public runtime surfaces: - `GET /health` @@ -78,12 +78,12 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | Frontend error surfacing | Closed and enforced by the toolchain rather than by review. All 47 `onError` handlers type the error as `Error` and pass it to `translateApiError`, which takes `unknown` and renders Spanish from the backend `code` per ADR 0014 — which also settles the old English-message inconsistency. The original bug was two handlers reading `e.response.data.detail`, an axios shape this codebase does not use, so the expression evaluated to `undefined` and the user saw a generic fallback; it survived because those handlers typed the error as `any`. Two things now stop it recurring: `@typescript-eslint/no-explicit-any` is an error and CI runs `npm run lint`, and `useUnknownInCatchVariables` closes the hole the linter cannot see — with `strict: false` a bare `catch (e)` was implicitly `any`, and reaching for a property on it now fails to compile. The only remaining `any` is scoped to `PortalBuilder.tsx` behind a justified file-level disable | Every mutation surfaces the backend's own message when it has one | Low | Keep it closed: the compiler and the linter enforce it. Lifting the `PortalBuilder.tsx` disable needs an explicit contract for block props first, and turning on full `strict` is a separate, much larger change — `strictNullChecks` is still off | | Runtime health signal | `GET /health` now checks Redis alongside the API and Postgres. It did not before, and that blind spot hid a real production outage: Redis was down on the deployed instance while `/health` reported `healthy`, because no ordinary HTTP route touches it — what fails silently is notifications, the workflow engine and the consumers. Verified both ways locally: stopping Redis turns the endpoint `unhealthy`, restarting it returns `healthy` | Health reflects every dependency whose loss degrades the product | Medium | When a new runtime dependency is added, add its probe in the same change set. A dependency only reached by background work is exactly the one an external probe cannot see | | Frontend alignment | Active routes are much cleaner than before, but docs still lag occasionally | UI backed by owned capabilities and current backend maturity | Medium | Refresh capability specs and route docs alongside feature delivery | -| Test coverage narrative | Codebase has 957 tests across 49 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | +| Test coverage narrative | Codebase has 974 tests across 50 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | | Contracts/Projects/HR production confirmation | Deploy readiness is locally verified, but no production-domain smoke evidence has been recorded in governance | Production dimension marked green only after deployed route smoke passes against the production domain and matching SHA | High | Run production smoke for `/contracts`, `/projects`, `/departments`, and `/employees` after deploy | | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | | Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR | -| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, and the quote PDF's dates and amounts. Dates and amounts already follow the language in the invoice PDF and the emails (`app/core/i18n/format.py`, an own table with the frontend's region defaults: `es` is `es-MX`, `en` is `en-US`; email dates stay ISO). English is offered: the backend catalogues (invoice PDF, 9 emails) are complete and `en` is in `SUPPORTED_LOCALES`, and the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces); it offers a language only once every namespace exists. The quote share dialog lets the seller choose the customer's language (`portal_sessions.locale`), which sets the language of the email sent to the customer; the portal page itself follows the customer's browser. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | +| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF and quote PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults. Dates and amounts follow the language in both PDFs and the emails (`app/core/i18n/format.py`, an own table with the frontend's region defaults: `es` is `es-MX`, `en` is `en-US`; email dates stay ISO). English is offered: the backend catalogues (invoice PDF, quote PDF, 9 emails) are complete and `en` is in `SUPPORTED_LOCALES`, and the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces); it offers a language only once every namespace exists. The quote share dialog lets the seller choose the customer's language (`portal_sessions.locale`), which sets the language of the email sent to the customer; the portal page itself follows the customer's browser. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | | Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away | --- diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index eb76001..63a4f6d 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -473,13 +473,15 @@ completa migrada no hay patron que seguir. el idioma del navegador del cliente, no el de la sesion. - [x] Decision de `babel` o tabla propia: **tabla propia** (`backend/app/core/i18n/format.py`), con las reglas por defecto del frontend (`es` -> `es-MX`, `en` -> `en-US`) mas - `es-ES` y `en-GB`. Aplicada al PDF de factura y a los importes de los correos; las - fechas de los correos siguen en ISO (no dependen del idioma). Falta aplicarla al PDF - de cotizacion, que entra con su catalogo. + `es-ES` y `en-GB`. Aplicada al PDF de factura, al de cotizacion y a los importes de los + correos; las fechas de los correos siguen en ISO (no dependen del idioma). + - [x] PDF de cotizacion (`sales/pdf.py`) con catalogo `quote_pdf` es/en, en el idioma + del usuario que lo descarga. El estado ya no sale como el codigo en ingles (`SENT`) + sino traducido (`ENVIADA`). - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: cadenas concatenadas, orden de palabras, plurales. El barrido estatico del frontend - salio limpio; quedan el PDF de cotizacion, los textos del portal y las etiquetas de - notificaciones, y el recorrido real en el navegador. + salio limpio; quedan los textos del portal, las etiquetas de notificaciones y + Discovery, y el recorrido real en el navegador. ---