diff --git a/README.md b/README.md index 34061c2..94cb49e 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 ``` -**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). --- @@ -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 @@ -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 | diff --git a/backend/app/modules/sales/service.py b/backend/app/modules/sales/service.py index fb3c0d5..df8e11c 100644 --- a/backend/app/modules/sales/service.py +++ b/backend/app/modules/sales/service.py @@ -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, @@ -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) @@ -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) @@ -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, ) @@ -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) @@ -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, @@ -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( @@ -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}" diff --git a/backend/tests/test_quote_history_events.py b/backend/tests/test_quote_history_events.py new file mode 100644 index 0000000..0d1210f --- /dev/null +++ b/backend/tests/test_quote_history_events.py @@ -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"] diff --git a/composable-os/src/components/sales/useQuoteEventText.test.tsx b/composable-os/src/components/sales/useQuoteEventText.test.tsx new file mode 100644 index 0000000..08e6d11 --- /dev/null +++ b/composable-os/src/components/sales/useQuoteEventText.test.tsx @@ -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 { + const created = i18next.createInstance(); + await created.init(buildI18nOptions(resources, lng)); + return created; +} + +const event = ( + event_type: string, + event_metadata: Record | 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 { + const i18n = await instance(lng); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useQuoteEventText(), { wrapper }); + return result.current(ev); +} + +// (tipo, datos, español, inglés) +const CASES: Array<[string, Record | 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"); + }); +}); diff --git a/composable-os/src/components/sales/useQuoteEventText.ts b/composable-os/src/components/sales/useQuoteEventText.ts new file mode 100644 index 0000000..a418cd4 --- /dev/null +++ b/composable-os/src/components/sales/useQuoteEventText.ts @@ -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 | 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; + + 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], + ); +} diff --git a/composable-os/src/i18n/english-sales.test.tsx b/composable-os/src/i18n/english-sales.test.tsx index 9c1b679..d9afe8b 100644 --- a/composable-os/src/i18n/english-sales.test.tsx +++ b/composable-os/src/i18n/english-sales.test.tsx @@ -87,7 +87,9 @@ beforeEach(() => { svc.getQuotePdfUrl.mockReturnValue("/pdf"); svc.getQuote.mockResolvedValue(fullQuote()); svc.getQuoteHistory.mockResolvedValue([ - { id: "e1", quote_id: "q1", user_id: null, event_type: "sent", description: "Quote sent by email", metadata: null, created_at: "2026-03-05T10:00:00Z" }, + // Un evento guardado por un servidor anterior, con la prosa en español: en inglés sale del catálogo. + { id: "e1", quote_id: "q1", user_id: null, event_type: "sent", description: "Cotización enviada", event_metadata: null, created_at: "2026-03-05T10:00:00Z" }, + { id: "e2", quote_id: "q1", user_id: null, event_type: "rejected", description: "Quote rejected. Reason: Too expensive", event_metadata: { reason: "Too expensive" }, created_at: "2026-03-05T11:00:00Z" }, ]); svc.updateLine.mockResolvedValue({}); svc.addLine.mockResolvedValue({}); @@ -262,7 +264,9 @@ describe("QuoteDetail in English", () => { } expect(screen.getByPlaceholderText("Notes visible to the customer...")).toHaveValue("Customer notes"); expect(screen.getByPlaceholderText("Quote terms...")).toHaveValue("Conditions"); - expect(await screen.findByText("Quote sent by email")).toBeInTheDocument(); + expect(await screen.findByText("Quote sent")).toBeInTheDocument(); + expect(screen.getByText("Quote rejected. Reason: Too expensive")).toBeInTheDocument(); + expect(screen.queryByText("Cotización enviada")).not.toBeInTheDocument(); }); it("offers the draft actions and adds a line with an English description", async () => { diff --git a/composable-os/src/locales/en/sales.json b/composable-os/src/locales/en/sales.json index ae41570..0f47b17 100644 --- a/composable-os/src/locales/en/sales.json +++ b/composable-os/src/locales/en/sales.json @@ -131,7 +131,17 @@ }, "history": { "title": "History", - "empty": "No events recorded." + "empty": "No events recorded.", + "events": { + "created": "Quote created: {{number}}", + "line_added": "Line added: {{description}}", + "line_removed": "Line removed: {{description}}", + "line_updated": "Line updated — {{changes}}", + "updated": "Lines updated (batch)", + "sent": "Quote sent", + "accepted": "Quote accepted — sales order created", + "rejected": "Quote rejected. Reason: {{reason}}" + } }, "toast": { "saveFailed": "Could not save: {{message}}", diff --git a/composable-os/src/locales/es/sales.json b/composable-os/src/locales/es/sales.json index 99d3243..21b7805 100644 --- a/composable-os/src/locales/es/sales.json +++ b/composable-os/src/locales/es/sales.json @@ -131,7 +131,17 @@ }, "history": { "title": "Historial", - "empty": "Sin eventos registrados." + "empty": "Sin eventos registrados.", + "events": { + "created": "Cotización creada: {{number}}", + "line_added": "Línea agregada: {{description}}", + "line_removed": "Línea eliminada: {{description}}", + "line_updated": "Línea modificada — {{changes}}", + "updated": "Líneas actualizadas (batch)", + "sent": "Cotización enviada", + "accepted": "Cotización aceptada — orden de venta creada", + "rejected": "Cotización rechazada. Razón: {{reason}}" + } }, "toast": { "saveFailed": "Error al guardar: {{message}}", diff --git a/composable-os/src/pages/QuoteDetail.test.tsx b/composable-os/src/pages/QuoteDetail.test.tsx index 6aff884..1bba3f1 100644 --- a/composable-os/src/pages/QuoteDetail.test.tsx +++ b/composable-os/src/pages/QuoteDetail.test.tsx @@ -19,7 +19,7 @@ vi.mock("@/services/portal", () => ({ portalService: portal })); vi.mock("sonner", () => ({ toast })); /** Datos del usuario (numeros, lineas, notas, eventos): no son texto de la interfaz. */ -const DATA = ["Q-001", "Propuesta Sol", "Zapatos", "Notas del cliente", "Condiciones", "Cotización enviada por correo", "ana@sol.co", "Z-1", "par"]; +const DATA = ["Q-001", "Propuesta Sol", "Zapatos", "Notas del cliente", "Condiciones", "Archivada por el equipo", "ana@sol.co", "Z-1", "par"]; const FORMATTED_DATE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/; const FORMATTED_DATETIME = /\d{1,2}:\d{2}/; const PERCENT = /^-?[\d.]+$/; @@ -44,7 +44,11 @@ beforeEach(() => { Object.values(toast).forEach((fn) => fn.mockReset()); svc.getQuote.mockResolvedValue(quote()); svc.getQuoteHistory.mockResolvedValue([ - { id: "e1", quote_id: "q1", user_id: null, event_type: "sent", description: "Cotización enviada por correo", metadata: null, created_at: "2026-03-05T10:00:00Z" }, + // Lo que guarda el servidor: el tipo y sus datos; `description` va en ingles de desarrollador. + { id: "e1", quote_id: "q1", user_id: null, event_type: "sent", description: "Quote sent", event_metadata: null, created_at: "2026-03-05T10:00:00Z" }, + { id: "e2", quote_id: "q1", user_id: null, event_type: "created", description: "Quote created: Q-001", event_metadata: { quote_number: "Q-001" }, created_at: "2026-03-05T09:00:00Z" }, + // Un tipo que el catalogo no conoce muestra lo que se guardo. + { id: "e3", quote_id: "q1", user_id: null, event_type: "archived", description: "Archivada por el equipo", event_metadata: null, created_at: "2026-03-05T11:00:00Z" }, ]); svc.updateLine.mockResolvedValue({}); svc.addLine.mockResolvedValue({}); @@ -75,7 +79,11 @@ describe("QuoteDetail, in Spanish", () => { } expect(screen.getByPlaceholderText("Notas visibles al cliente...")).toHaveValue("Notas del cliente"); expect(screen.getByPlaceholderText("Términos de la cotización...")).toHaveValue("Condiciones"); - expect(await screen.findByText("Cotización enviada por correo")).toBeInTheDocument(); + // El historial sale en el idioma de la interfaz, no en el que escribio el servidor. + expect(await screen.findByText("Cotización creada: Q-001")).toBeInTheDocument(); + expect(screen.getByText("Archivada por el equipo")).toBeInTheDocument(); + expect(screen.queryByText("Quote sent")).not.toBeInTheDocument(); + expect(screen.getByText("Cotización enviada")).toBeInTheDocument(); }); it("offers the draft actions and adds a line with a Spanish description", async () => { diff --git a/composable-os/src/pages/QuoteDetail.tsx b/composable-os/src/pages/QuoteDetail.tsx index 4897e7c..7365553 100644 --- a/composable-os/src/pages/QuoteDetail.tsx +++ b/composable-os/src/pages/QuoteDetail.tsx @@ -10,6 +10,7 @@ import { } from "@/services/sales"; import { portalService, type PortalSession } from "@/services/portal"; import { QuoteStatusBadge } from "@/components/sales/QuoteStatusBadge"; +import { useQuoteEventText } from "@/components/sales/useQuoteEventText"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -90,6 +91,7 @@ export default function QuoteDetail() { queryFn: () => salesService.getQuoteHistory(id!), enabled: !!id, }); + const eventText = useQuoteEventText(); const invalidateQuote = () => { qc.invalidateQueries({ queryKey: ["quote", id] }); @@ -620,7 +622,7 @@ export default function QuoteDetail() { {formatDateTime(ev.created_at)} - {ev.description} + {eventText(ev)} ))} diff --git a/composable-os/src/services/sales.ts b/composable-os/src/services/sales.ts index e06b483..2e3833a 100644 --- a/composable-os/src/services/sales.ts +++ b/composable-os/src/services/sales.ts @@ -125,7 +125,8 @@ export interface QuoteEvent { user_id: string | null; event_type: string; description: string; - metadata: Record | null; + /** Los datos del evento (el servidor lo manda como `event_metadata`). */ + event_metadata: Record | null; created_at: string; } diff --git a/docs/CAPABILITY_MATURITY_SCORECARD.md b/docs/CAPABILITY_MATURITY_SCORECARD.md index a2841b5..f9a71f6 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. -**972 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. +**979 tests across 51 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 f781311..40d7019 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: 972 automated tests across 50 test files +- Test suite: 979 automated tests across 51 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 972 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 | +| Test coverage narrative | Codebase has 979 tests across 51 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 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 the workflow `send_email` action's defaults (the portal's API messages are English and the notification texts come from the interface catalogue, `layout:notifications.events`). 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 | +| 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 the workflow `send_email` action's defaults (the portal's API messages are English and the notification texts come from the interface catalogue, `layout:notifications.events`, and so do the quote history texts, `sales:detail.history.events`; quote events saved before that keep their stored Spanish text). 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 15f863e..19ac585 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -483,10 +483,21 @@ completa migrada no hay patron que seguir. pantalla lo mostraba) y las notificaciones en vivo dejan de llevar `title`: el servidor manda el tipo de evento y la interfaz pone el texto (`layout:notifications.events.*`, es y en). - - [ ] 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 Discovery (asistente, blueprint, capacidades) y el recorrido - real en el navegador. + - [x] Recorrido en ingles sin navegador (Chrome no alcanzaba los puertos de WSL): las 15 + paginas con el catalogo `en` real y datos reales sembrados por API. Salio limpio + salvo tres cosas: el historial de la cotizacion (siguiente punto), la accion + `send_email` sin etiqueta en `workflows:actionType` y el vencimiento del enlace + con fecha corta numerica (`10/12/2026`, ambigua en ingles). + - [x] Historial de la cotizacion: `sales/service.py` guardaba ocho textos en espanol en + `quote_events.description` y la interfaz los pintaba tal cual. Ahora guarda el tipo y + los datos (`event_metadata`) y la interfaz pone el texto + (`sales:detail.history.events.*`, es y en); `description` pasa a ingles de + desarrollador y es la reserva. Los eventos ya guardados conservan su texto en + espanol salvo los que no necesitan datos (enviada, aceptada, lotes), que se + traducen igualmente; reescribir el resto es una migracion de datos aparte. + - [ ] Pendientes del recorrido: etiqueta de `send_email`, fecha corta del vencimiento del + enlace, Discovery (asistente, blueprint, capacidades) y repetir el recorrido con + navegador cuando Chrome alcance el entorno. ---