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
```

**972 tests** (contrato + integración + e2e) en 50 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones).
**979 tests** (contrato + integración + e2e) en 51 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/ 972 tests (pytest-asyncio)
│ ├── tests/ 979 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
```

**972 tests** en 50 archivos. Las categorías no se solapan: cada archivo cuenta
**979 tests** en 51 archivos. Las categorías no se solapan: cada archivo cuenta
una sola vez y las filas suman 41.

| Categoría | Archivos | Cobertura |
Expand Down
32 changes: 24 additions & 8 deletions backend/app/modules/sales/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,11 @@ async def create_quote(
await db.refresh(quote, attribute_names=["lines"])
await _recalculate_totals(db, quote)

_log_event(db, workspace_id, quote.id, actor_id, "created", f"Cotización creada: {quote.quote_number}")
_log_event(
db, workspace_id, quote.id, actor_id, "created",
f"Quote created: {quote.quote_number}",
metadata={"quote_number": quote.quote_number},
)

await publish_event(Event(
event_type=QUOTE_CREATED,
Expand Down Expand Up @@ -330,7 +334,11 @@ async def add_line(

await db.refresh(quote, attribute_names=["lines"])
await _recalculate_totals(db, quote)
_log_event(db, workspace_id, quote_id, None, "line_added", f"Línea agregada: {data.description}")
_log_event(
db, workspace_id, quote_id, None, "line_added",
f"Line added: {data.description}",
metadata={"description": data.description},
)
await db.commit()
return await _reload_quote(db, workspace_id, quote_id)

Expand Down Expand Up @@ -363,7 +371,11 @@ async def remove_line(
await db.flush()
await db.refresh(quote, attribute_names=["lines"])
await _recalculate_totals(db, quote)
_log_event(db, workspace_id, quote_id, None, "line_removed", f"Línea eliminada: {line.description}")
_log_event(
db, workspace_id, quote_id, None, "line_removed",
f"Line removed: {line.description}",
metadata={"description": line.description},
)
await db.commit()
return await _reload_quote(db, workspace_id, quote_id)

Expand Down Expand Up @@ -407,7 +419,7 @@ async def update_line(
desc_parts = [f"{k}: {v}" for k, v in changes.items()]
_log_event(
db, workspace_id, quote_id, actor_id, "line_updated",
f"Línea modificada — {', '.join(desc_parts)}",
f"Line updated — {', '.join(desc_parts)}",
metadata=changes,
)

Expand Down Expand Up @@ -494,7 +506,7 @@ async def replace_lines(
await db.flush()
await db.refresh(quote, attribute_names=["lines"])
await _recalculate_totals(db, quote)
_log_event(db, workspace_id, quote_id, actor_id, "updated", "Líneas actualizadas (batch)")
_log_event(db, workspace_id, quote_id, actor_id, "updated", "Lines updated (batch)")

await db.commit()
return await _reload_quote(db, workspace_id, quote_id)
Expand Down Expand Up @@ -537,7 +549,7 @@ async def send_quote(

quote.status = "sent"
quote.sent_at = datetime.now(timezone.utc)
_log_event(db, workspace_id, quote_id, actor_id, "sent", "Cotización enviada")
_log_event(db, workspace_id, quote_id, actor_id, "sent", "Quote sent")

await publish_event(Event(
event_type=QUOTE_SENT,
Expand Down Expand Up @@ -570,7 +582,7 @@ async def accept_quote(
now = datetime.now(timezone.utc)
quote.status = "accepted"
quote.accepted_at = now
_log_event(db, workspace_id, quote_id, actor_id, "accepted", "Cotización aceptada — orden de venta creada")
_log_event(db, workspace_id, quote_id, actor_id, "accepted", "Quote accepted — sales order created")

order_number = await _next_order_number(db, workspace_id)
order = SalesOrder(
Expand Down Expand Up @@ -650,7 +662,11 @@ async def reject_quote(

quote.status = "rejected"
quote.rejected_at = datetime.now(timezone.utc)
_log_event(db, workspace_id, quote_id, actor_id, "rejected", f"Cotización rechazada. Razón: {data.reason or '—'}")
_log_event(
db, workspace_id, quote_id, actor_id, "rejected",
f"Quote rejected. Reason: {data.reason or '—'}",
metadata={"reason": data.reason},
)
if data.reason:
quote.notes = (quote.notes or "") + f"\nRejection reason: {data.reason}"

Expand Down
125 changes: 125 additions & 0 deletions backend/tests/test_quote_history_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""
El historial de una cotizacion guarda codigos y datos, no prosa (ADR 0014, tarea 12).

Antes cada evento guardaba su texto en espanol («Cotizacion enviada», «Linea agregada:
...») y la interfaz lo pintaba tal cual, de modo que quien usaba la app en ingles veia
espanol. Ahora el servidor guarda el tipo del evento y sus datos en `event_metadata`;
la interfaz pone el texto (`sales:detail.history.events.*`). `description` queda como
reserva, en ingles de desarrollador como el resto de los `message` de la API.
"""
import re

import pytest
from httpx import AsyncClient

pytestmark = pytest.mark.asyncio

BASE = "/api/v1/sales"


async def _quote(client: AsyncClient, headers: dict, title: str = "History quote") -> dict:
resp = await client.post(f"{BASE}/quotes", headers=headers, json={
"title": title, "currency": "USD", "tax_rate": 0.0, "discount_amount": 0.0,
"lines": [{"description": "Widget A", "quantity": 2, "unit_price": 100.0,
"discount_percent": 0.0, "line_order": 1}],
})
assert resp.status_code == 201, resp.text
return resp.json()


async def _events(client: AsyncClient, headers: dict, quote_id: str) -> dict[str, dict]:
resp = await client.get(f"{BASE}/quotes/{quote_id}/history", headers=headers)
assert resp.status_code == 200, resp.text
return {event["event_type"]: event for event in resp.json()}


async def test_created_carries_the_quote_number(client: AsyncClient, auth_headers: dict):
quote = await _quote(client, auth_headers)

event = (await _events(client, auth_headers, quote["id"]))["created"]

assert event["event_metadata"] == {"quote_number": quote["quote_number"]}
assert event["description"] == f"Quote created: {quote['quote_number']}"


async def test_added_and_removed_lines_carry_their_description(client: AsyncClient, auth_headers: dict):
quote = await _quote(client, auth_headers)
added = await client.post(f"{BASE}/quotes/{quote['id']}/lines", headers=auth_headers, json={
"description": "Extra service", "quantity": 1, "unit_price": 50.0, "line_order": 2,
})
assert added.status_code in (200, 201), added.text
line = next(l for l in added.json()["lines"] if l["description"] == "Extra service")
removed = await client.delete(f"{BASE}/quotes/{quote['id']}/lines/{line['id']}", headers=auth_headers)
assert removed.status_code in (200, 204), removed.text

events = await _events(client, auth_headers, quote["id"])

assert events["line_added"]["event_metadata"] == {"description": "Extra service"}
assert events["line_removed"]["event_metadata"] == {"description": "Extra service"}
assert events["line_added"]["description"] == "Line added: Extra service"
assert events["line_removed"]["description"] == "Line removed: Extra service"


async def test_an_updated_line_keeps_what_changed(client: AsyncClient, auth_headers: dict):
quote = await _quote(client, auth_headers)
line_id = quote["lines"][0]["id"]

resp = await client.patch(
f"{BASE}/quotes/{quote['id']}/lines/{line_id}", headers=auth_headers, json={"unit_price": 150.0}
)
assert resp.status_code == 200, resp.text

event = (await _events(client, auth_headers, quote["id"]))["line_updated"]

assert event["event_metadata"] == {"unit_price": 150.0}
assert event["description"].startswith("Line updated — ")


async def test_sending_and_accepting_need_no_data(client: AsyncClient, auth_headers: dict):
quote = await _quote(client, auth_headers)
assert (await client.patch(f"{BASE}/quotes/{quote['id']}/send", headers=auth_headers, json={})).status_code == 200
assert (await client.patch(f"{BASE}/quotes/{quote['id']}/accept", headers=auth_headers, json={})).status_code == 200

events = await _events(client, auth_headers, quote["id"])

assert events["sent"]["description"] == "Quote sent"
assert events["accepted"]["description"] == "Quote accepted — sales order created"


async def test_a_rejection_carries_its_reason(client: AsyncClient, auth_headers: dict):
quote = await _quote(client, auth_headers)
resp = await client.patch(
f"{BASE}/quotes/{quote['id']}/reject", headers=auth_headers, json={"reason": "Too expensive"}
)
assert resp.status_code == 200, resp.text

event = (await _events(client, auth_headers, quote["id"]))["rejected"]

assert event["event_metadata"] == {"reason": "Too expensive"}
assert event["description"] == "Quote rejected. Reason: Too expensive"


async def test_a_rejection_without_reason_says_so_in_the_data(client: AsyncClient, auth_headers: dict):
quote = await _quote(client, auth_headers)
resp = await client.patch(f"{BASE}/quotes/{quote['id']}/reject", headers=auth_headers, json={})
assert resp.status_code == 200, resp.text

event = (await _events(client, auth_headers, quote["id"]))["rejected"]

# `reason` presente y nulo: la interfaz lo distingue de un evento antiguo sin datos.
assert event["event_metadata"] == {"reason": None}


async def test_no_description_is_written_in_spanish(client: AsyncClient, auth_headers: dict):
quote = await _quote(client, auth_headers)
line_id = quote["lines"][0]["id"]
await client.patch(f"{BASE}/quotes/{quote['id']}/lines/{line_id}", headers=auth_headers, json={"quantity": 3})
await client.patch(f"{BASE}/quotes/{quote['id']}/send", headers=auth_headers, json={})
await client.patch(f"{BASE}/quotes/{quote['id']}/reject", headers=auth_headers, json={"reason": "No"})

resp = await client.get(f"{BASE}/quotes/{quote['id']}/history", headers=auth_headers)
spanish = re.compile(r"[áéíóúñ]|Cotizaci|L[ií]nea|Raz[oó]n|enviada|rechazada|aceptada", re.IGNORECASE)

assert len(resp.json()) >= 4 # el control puede fallar: sin eventos pasaria sin mirar nada
for event in resp.json():
assert not spanish.search(event["description"].replace("—", "-")), event["description"]
84 changes: 84 additions & 0 deletions composable-os/src/components/sales/useQuoteEventText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { renderHook } from "@testing-library/react";
import i18next, { type i18n as I18n } from "i18next";
import type { ReactNode } from "react";
import { I18nextProvider } from "react-i18next";
import { describe, expect, it } from "vitest";

import { buildI18nOptions, resources } from "@/i18n";
import type { QuoteEvent } from "@/services/sales";

import { useQuoteEventText } from "./useQuoteEventText";

async function instance(lng: "es" | "en"): Promise<I18n> {
const created = i18next.createInstance();
await created.init(buildI18nOptions(resources, lng));
return created;
}

const event = (
event_type: string,
event_metadata: Record<string, unknown> | null,
description = "stored description",
): QuoteEvent => ({
id: "e1", quote_id: "q1", user_id: null, event_type, description, event_metadata,
created_at: "2026-10-09T23:23:00Z",
});

async function textOf(lng: "es" | "en", ev: QuoteEvent): Promise<string> {
const i18n = await instance(lng);
const wrapper = ({ children }: { children: ReactNode }) => (
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
);
const { result } = renderHook(() => useQuoteEventText(), { wrapper });
return result.current(ev);
}

// (tipo, datos, español, inglés)
const CASES: Array<[string, Record<string, unknown> | null, string, string]> = [
["created", { quote_number: "Q-1" }, "Cotización creada: Q-1", "Quote created: Q-1"],
["line_added", { description: "Widget" }, "Línea agregada: Widget", "Line added: Widget"],
["line_removed", { description: "Widget" }, "Línea eliminada: Widget", "Line removed: Widget"],
["line_updated", { quantity: 3, unit_price: 9 }, "Línea modificada — quantity: 3, unit_price: 9", "Line updated — quantity: 3, unit_price: 9"],
["updated", null, "Líneas actualizadas (batch)", "Lines updated (batch)"],
["sent", null, "Cotización enviada", "Quote sent"],
["accepted", null, "Cotización aceptada — orden de venta creada", "Quote accepted — sales order created"],
["rejected", { reason: "Muy caro" }, "Cotización rechazada. Razón: Muy caro", "Quote rejected. Reason: Muy caro"],
];

describe("useQuoteEventText", () => {
it.each(CASES)("%s, en español", async (type, meta, es) => {
expect(await textOf("es", event(type, meta))).toBe(es);
});

it.each(CASES)("%s, en inglés", async (type, meta, _es, en) => {
expect(await textOf("en", event(type, meta))).toBe(en);
});

it("el mismo evento sale en el idioma de la interfaz, no en el que lo escribio el servidor", async () => {
const ev = event("sent", null, "Cotización enviada");
expect(await textOf("en", ev)).toBe("Quote sent");
});

it("un rechazo sin motivo muestra un guion", async () => {
expect(await textOf("en", event("rejected", { reason: null }))).toBe("Quote rejected. Reason: —");
expect(await textOf("es", event("rejected", { reason: null }))).toBe("Cotización rechazada. Razón: —");
});

it("un evento anterior, sin los datos que su texto necesita, muestra lo que se guardo", async () => {
for (const type of ["created", "line_added", "line_removed", "line_updated", "rejected"]) {
expect(await textOf("en", event(type, null, "Línea agregada: Antiguo"))).toBe("Línea agregada: Antiguo");
}
});

it("un evento antiguo que no necesita datos sí se traduce", async () => {
expect(await textOf("en", event("sent", null, "Cotización enviada"))).toBe("Quote sent");
});

it("un tipo que el catálogo no conoce muestra lo que se guardo", async () => {
expect(await textOf("en", event("archived", { x: 1 }, "Quote archived"))).toBe("Quote archived");
});

it("no inventa datos: un texto vacío en los datos cuenta como ausente", async () => {
expect(await textOf("en", event("created", { quote_number: "" }, "stored"))).toBe("stored");
});
});
64 changes: 64 additions & 0 deletions composable-os/src/components/sales/useQuoteEventText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";

import type { QuoteEvent } from "@/services/sales";

const text = (value: unknown): string | null =>
typeof value === "string" && value ? value : null;

/**
* Los datos que el texto de cada tipo de evento necesita, o `null` si el evento no
* los trae (los eventos anteriores a que el servidor guardara el dato en
* `event_metadata`): en ese caso se muestra su `description` tal como se guardo.
*/
function paramsFor(event: QuoteEvent): Record<string, string> | null {
const meta = event.event_metadata ?? {};

switch (event.event_type) {
case "created": {
const number = text(meta.quote_number);
return number ? { number } : null;
}
case "line_added":
case "line_removed": {
const description = text(meta.description);
return description ? { description } : null;
}
case "line_updated": {
// `event_metadata` guarda aqui lo que cambio: campo -> valor.
const changes = Object.entries(meta).map(([field, value]) => `${field}: ${String(value)}`);
return changes.length ? { changes: changes.join(", ") } : null;
}
case "rejected":
return "reason" in meta ? { reason: text(meta.reason) ?? "—" } : null;
case "updated":
case "sent":
case "accepted":
return {};
default:
return null;
}
}

/**
* El texto de un evento del historial de una cotizacion, en el idioma de la interfaz.
*
* El servidor guarda el tipo del evento y sus datos (ADR 0014: el backend responde
* codigos y la interfaz los traduce). `description` es la reserva para un tipo que el
* catalogo no conoce o un evento sin datos.
*/
export function useQuoteEventText() {
const { t, i18n } = useTranslation();
const translate = t as unknown as (key: string, params: Record<string, string>) => string;

return useCallback(
(event: QuoteEvent): string => {
const params = paramsFor(event);
const key = `sales:detail.history.events.${event.event_type}`;
return params && i18n.exists(key) ? translate(key, params) : event.description;
},
// `t` cambia de identidad al cambiar el idioma, que es lo que debe re-renderizar.
// eslint-disable-next-line react-hooks/exhaustive-deps
[i18n, t],
);
}
Loading
Loading