Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
14 changes: 11 additions & 3 deletions backend/app/core/i18n/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(".")
Comment on lines +71 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve quantities smaller than six decimal places

When a quote contains an accepted positive quantity below 0.0000005 (the sales schemas impose no minimum precision), formatting with six fixed decimal places rounds it to zero before trimming, so a quantity such as 0.0000001 is printed as 0 in the PDF. The previous :g rendering retained this value as 1e-07; the variable-decimal path should preserve enough significant digits rather than silently changing a nonzero quantity.

Useful? React with 👍 / 👎.

else:
text = f"{value:,.{decimals}f}"
integer_digits = len(text.split(".")[0].lstrip("-").replace(",", ""))
if integer_digits < rule.min_grouping + 3:
text = text.replace(",", "")
Expand Down
38 changes: 38 additions & 0 deletions backend/app/core/i18n/locales/en/quote_pdf.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
38 changes: 38 additions & 0 deletions backend/app/core/i18n/locales/es/quote_pdf.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
80 changes: 55 additions & 25 deletions backend/app/modules/sales/pdf.py
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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)
Expand All @@ -47,24 +63,25 @@ 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(
quote: Quote,
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
Expand All @@ -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:
Expand All @@ -93,19 +110,26 @@ 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
pdf.set_xy(x_left, max(pdf.get_y(), y_start + 36))
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)
Expand All @@ -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
Expand All @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions backend/app/modules/sales/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
)
Expand All @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions backend/tests/data/quote_pdf_es_golden.txt
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading