From 45f128a551fb9a9fef222c2d2cbaa314e5780072 Mon Sep 17 00:00:00 2001 From: Aduanasoft Date: Thu, 16 Jul 2026 09:54:57 -0600 Subject: [PATCH 01/40] fix(deploy): apk add wget best-effort en Dockerfile.prod (redes sin CDN de Alpine) Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/Dockerfile.prod | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 336991d..910a3eb 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -49,7 +49,8 @@ FROM node:22-alpine AS runtime WORKDIR /app -RUN apk add --no-cache wget +# wget de busybox ya viene en alpine; el apk es best-effort (redes restringidas sin CDN de Alpine) +RUN apk add --no-cache wget || true RUN npm config set strict-ssl false && \ npm install -g pnpm From a613a7a6aa9271ba14966a2553816848b9b96f6e Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 16 Jul 2026 13:19:21 -0600 Subject: [PATCH 02/40] =?UTF-8?q?feat(crm,workspace):=20alta=20de=20organi?= =?UTF-8?q?zaciones=20y=20usuarios=20v=C3=ADa=20Hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nueva sección "Workspace" (Organizaciones + Usuarios) que orquesta el Hub reenviando el token del usuario autenticado. Sin secretos en el CRM ni bypass: la autorización la impone el Hub (hub_admin para tenants; hub_admin o admin del tenant para invitaciones). - Organizaciones: listar (GET /api/v1/hub/tenants) y crear (POST /api/v1/hub/tenants → tenant + realm Keycloak). - Usuarios: invitación de un solo uso (POST /api/v1/hub/invites) con enlace copiable si el correo no llega. - Se descarta /auth/provision-user (PROVISION_SECRET, machine-to-machine) en favor del flujo de invitación con token de admin. - Helpers puros (slug, validación, extracción de errores del Hub) con tests unitarios; estado "requiere permisos" ante 403 y fallback a slug manual para admin de tenant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/sidebar/modules.ts | 10 + .../server/workspace-provision.shared.test.ts | 80 ++++++++ .../lib/server/workspace-provision.shared.ts | 85 +++++++++ .../src/lib/server/workspace-provision.ts | 126 ++++++++++++ .../workspace/organizaciones/+page.server.ts | 78 ++++++++ .../workspace/organizaciones/+page.svelte | 180 ++++++++++++++++++ .../workspace/usuarios/+page.server.ts | 63 ++++++ .../dashboard/workspace/usuarios/+page.svelte | 152 +++++++++++++++ 8 files changed, 774 insertions(+) create mode 100644 frontend/src/lib/server/workspace-provision.shared.test.ts create mode 100644 frontend/src/lib/server/workspace-provision.shared.ts create mode 100644 frontend/src/lib/server/workspace-provision.ts create mode 100644 frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts create mode 100644 frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte create mode 100644 frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts create mode 100644 frontend/src/routes/dashboard/workspace/usuarios/+page.svelte diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index dbd39e0..6792c2b 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -6,6 +6,7 @@ import { Briefcase, Ship, Receipt, + Building2, } from '@lucide/svelte'; export type SystemContext = 'fixed_asset' | 'inventory'; @@ -69,6 +70,15 @@ export function getNavMain(): NavMainItem[] { { title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, ], }, + { + title: 'Workspace', + url: '/dashboard/workspace/organizaciones', + icon: Building2, + items: [ + { title: 'Organizaciones', url: '/dashboard/workspace/organizaciones' }, + { title: 'Usuarios (invitaciones)', url: '/dashboard/workspace/usuarios' }, + ], + }, { title: 'Usuarios', url: '/dashboard/users', diff --git a/frontend/src/lib/server/workspace-provision.shared.test.ts b/frontend/src/lib/server/workspace-provision.shared.test.ts new file mode 100644 index 0000000..e9b3ab7 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.shared.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { + slugifyTenantName, + validateTenantForm, + hubErrorMessage, + isForbiddenStatus, + TENANT_SLUG_RE +} from './workspace-provision.shared'; + +describe('slugifyTenantName', () => { + it('convierte nombre con acentos y espacios a slug válido', () => { + const slug = slugifyTenantName('Logística Peña & Cía S.A. de C.V.'); + expect(slug).toBe('logistica-pena-cia-s-a-de-c-v'); + expect(TENANT_SLUG_RE.test(slug)).toBe(true); + }); + + it('quita guiones al inicio y al final', () => { + expect(slugifyTenantName(' --Hola-- ')).toBe('hola'); + }); + + it('cadena sin caracteres válidos da string vacío', () => { + expect(slugifyTenantName('!!!')).toBe(''); + }); +}); + +describe('validateTenantForm', () => { + it('acepta datos válidos', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'a@b.com' }) + ).toBeNull(); + }); + + it('rechaza nombre demasiado corto', () => { + expect( + validateTenantForm({ name: 'A', slug: 'a-b', contact_email: 'a@b.com' }) + ).toMatch(/al menos 2/); + }); + + it('rechaza slug con mayúsculas o espacios', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'Empresa ABC', contact_email: 'a@b.com' }) + ).toMatch(/slug/i); + }); + + it('rechaza email inválido', () => { + expect( + validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'no-email' }) + ).toMatch(/email/i); + }); +}); + +describe('hubErrorMessage', () => { + it('devuelve el detail string tal cual', () => { + expect(hubErrorMessage({ detail: 'Slug ya existe' }, 409)).toBe('Slug ya existe'); + }); + + it('formatea el primer error de validación de Pydantic', () => { + const body = { detail: [{ loc: ['body', 'contact_email'], msg: 'value is not a valid email' }] }; + expect(hubErrorMessage(body, 422)).toBe('contact_email: value is not a valid email'); + }); + + it('mensaje de permisos ante 403 sin detail', () => { + expect(hubErrorMessage(null, 403)).toMatch(/permisos/i); + }); + + it('mensaje genérico con status ante cuerpo desconocido', () => { + expect(hubErrorMessage(null, 500)).toMatch(/500/); + }); +}); + +describe('isForbiddenStatus', () => { + it('true para 401 y 403', () => { + expect(isForbiddenStatus(401)).toBe(true); + expect(isForbiddenStatus(403)).toBe(true); + }); + it('false para otros', () => { + expect(isForbiddenStatus(422)).toBe(false); + expect(isForbiddenStatus(200)).toBe(false); + }); +}); diff --git a/frontend/src/lib/server/workspace-provision.shared.ts b/frontend/src/lib/server/workspace-provision.shared.ts new file mode 100644 index 0000000..e71b283 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.shared.ts @@ -0,0 +1,85 @@ +/** + * Helpers puros para el alta de organizaciones/usuarios en el Workspace (Hub). + * Sin dependencias de entorno para poder testearse en aislamiento (Vitest). + */ + +// Slug del tenant: mismas reglas que el Hub (TenantCreateDTO.slug → ^[a-z0-9-]+$). +export const TENANT_SLUG_RE = /^[a-z0-9-]+$/; + +// Roles válidos para invitar un usuario. Son los que el Hub reconoce en su +// flujo de alta (ver ProvisionUserRequestDTO / create_invite). No inventar otros. +export const WORKSPACE_INVITE_ROLES = ['user', 'admin', 'supervisor', 'operador', 'visor'] as const; +export type WorkspaceInviteRole = (typeof WORKSPACE_INVITE_ROLES)[number]; + +/** + * Deriva un slug candidato a partir del nombre de la organización: + * minúsculas, sin acentos, espacios y símbolos → guiones. + */ +export function slugifyTenantName(name: string): string { + return name + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') // quita acentos (marcas combinantes Unicode) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 100); +} + +export type TenantFormValues = { + name: string; + slug: string; + contact_email: string; +}; + +/** + * Valida los campos obligatorios del alta de organización antes de llamar al Hub, + * para dar feedback inmediato sin gastar un round-trip. + * Devuelve un mensaje de error o null si es válido. + */ +export function validateTenantForm(values: TenantFormValues): string | null { + if (!values.name || values.name.trim().length < 2) { + return 'El nombre de la organización debe tener al menos 2 caracteres.'; + } + if (!TENANT_SLUG_RE.test(values.slug)) { + return 'El slug solo admite minúsculas, dígitos y guiones (sin espacios ni acentos).'; + } + if (!values.contact_email || !values.contact_email.includes('@')) { + return 'El email de contacto es obligatorio y debe ser válido.'; + } + return null; +} + +/** + * Extrae un mensaje legible del cuerpo de error de FastAPI (Hub). + * - 401/403 → mensaje de permisos. + * - detail string → tal cual. + * - detail array (422 Pydantic) → "campo: msg" del primer error. + * - cualquier otro → mensaje genérico con el status. + */ +export function hubErrorMessage(body: unknown, status: number): string { + const detail = + body && typeof body === 'object' ? (body as { detail?: unknown }).detail : null; + + if (typeof detail === 'string' && detail.trim()) { + return detail; + } + + if (Array.isArray(detail) && detail.length > 0) { + const first = detail[0] as { loc?: unknown[]; msg?: string }; + const loc = Array.isArray(first.loc) ? first.loc : []; + const field = loc.length ? String(loc[loc.length - 1]) : ''; + const msg = first.msg ?? 'dato inválido'; + return field ? `${field}: ${msg}` : msg; + } + + if (status === 401 || status === 403) { + return 'No tienes permisos de administrador en el workspace para esta acción.'; + } + + return `El workspace respondió con un error (${status}).`; +} + +/** True si el status del Hub indica falta de permisos/sesión. */ +export function isForbiddenStatus(status: number): boolean { + return status === 401 || status === 403; +} diff --git a/frontend/src/lib/server/workspace-provision.ts b/frontend/src/lib/server/workspace-provision.ts new file mode 100644 index 0000000..4bc5af1 --- /dev/null +++ b/frontend/src/lib/server/workspace-provision.ts @@ -0,0 +1,126 @@ +/** + * Orquestación del Hub (Workspace) para dar de alta ORGANIZACIONES (tenants) y + * USUARIOS (invitaciones) desde el CRM. + * + * Principio de seguridad: SIEMPRE se llama server-side reenviando el token del + * usuario autenticado (Bearer). Es el Hub quien valida el permiso — + * `hub_admin`/superadmin para tenants, `hub_admin` o `admin` del tenant para + * invitaciones. El CRM NO guarda secretos ni hace bypass de autorización. + * + * Endpoints del Hub (base = getHubBackendUrl()): + * GET /api/v1/hub/tenants → lista de organizaciones (solo hub_admin) + * POST /api/v1/hub/tenants → crea tenant + realm Keycloak (solo hub_admin) + * POST /api/v1/hub/invites → invitación de un solo uso + email (hub_admin | admin del tenant) + */ + +import { getHubBackendUrl } from '$lib/server/workspace-auth'; +import { hubErrorMessage, isForbiddenStatus } from '$lib/server/workspace-provision.shared'; + +const HUB_API_PREFIX = '/api/v1/hub'; + +function hubUrl(path: string): string { + return `${getHubBackendUrl()}${HUB_API_PREFIX}${path}`; +} + +export type WorkspaceTenant = { + id: number; + name: string; + slug: string; + display_name?: string | null; + contact_name?: string | null; + contact_email: string; + status: string; + is_self_hosted: boolean; + has_license: boolean; + created_at: string; +}; + +export type WorkspaceInvite = { + id: number; + email: string; + tenant_slug: string; + role: string; + invite_url: string; + expires_at: string; + created_at: string; +}; + +export type HubResult = + | { ok: true; data: T } + | { ok: false; status: number; forbidden: boolean; error: string }; + +/** Construye el resultado de error a partir de una respuesta no-2xx del Hub. */ +async function toErrorResult(res: Response): Promise> { + const body = await res.json().catch(() => null); + return { + ok: false, + status: res.status, + forbidden: isForbiddenStatus(res.status), + error: hubErrorMessage(body, res.status) + }; +} + +const jsonHeaders = (accessToken: string) => ({ + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' +}); + +/** Lista las organizaciones del workspace. Requiere hub_admin (403 si no). */ +export async function listWorkspaceTenants( + accessToken: string, + fetch: typeof globalThis.fetch +): Promise> { + const res = await fetch(hubUrl('/tenants'), { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) return toErrorResult(res); + const data = (await res.json()) as { tenants?: WorkspaceTenant[] }; + return { ok: true, data: Array.isArray(data.tenants) ? data.tenants : [] }; +} + +export type CreateTenantInput = { + name: string; + slug: string; + contact_email: string; + contact_name?: string; + contact_phone?: string; + display_name?: string; + is_self_hosted: boolean; + app_url?: string; +}; + +/** Crea una organización (tenant) y provisiona su realm en Keycloak. */ +export async function createWorkspaceTenant( + accessToken: string, + fetch: typeof globalThis.fetch, + input: CreateTenantInput +): Promise> { + const res = await fetch(hubUrl('/tenants'), { + method: 'POST', + headers: jsonHeaders(accessToken), + body: JSON.stringify(input) + }); + if (!res.ok) return toErrorResult(res); + return { ok: true, data: (await res.json()) as WorkspaceTenant }; +} + +export type CreateInviteInput = { + email: string; + tenant_slug: string; + role: string; +}; + +/** Genera una invitación de un solo uso para un usuario en un tenant. */ +export async function createWorkspaceInvite( + accessToken: string, + fetch: typeof globalThis.fetch, + input: CreateInviteInput +): Promise> { + const res = await fetch(hubUrl('/invites'), { + method: 'POST', + headers: jsonHeaders(accessToken), + body: JSON.stringify(input) + }); + if (!res.ok) return toErrorResult(res); + return { ok: true, data: (await res.json()) as WorkspaceInvite }; +} diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts new file mode 100644 index 0000000..aab020a --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts @@ -0,0 +1,78 @@ +import { fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { getAuthTokens } from '$lib/server/api'; +import { + listWorkspaceTenants, + createWorkspaceTenant, + type CreateTenantInput +} from '$lib/server/workspace-provision'; +import { validateTenantForm } from '$lib/server/workspace-provision.shared'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) { + return { tenants: [], forbidden: true, loadError: null }; + } + + const res = await listWorkspaceTenants(accessToken, fetch); + if (!res.ok) { + // 401/403 → el usuario no es admin del workspace: se muestra estado informativo, + // no un error. Otros status sí se reportan como error de carga. + return { tenants: [], forbidden: res.forbidden, loadError: res.forbidden ? null : res.error }; + } + + return { tenants: res.data, forbidden: false, loadError: null }; +}; + +export const actions: Actions = { + create: async ({ request, cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); + + const data = await request.formData(); + const name = String(data.get('name') ?? '').trim(); + const slug = String(data.get('slug') ?? '') + .trim() + .toLowerCase(); + const contact_email = String(data.get('contact_email') ?? '').trim(); + const contact_name = String(data.get('contact_name') ?? '').trim(); + const contact_phone = String(data.get('contact_phone') ?? '').trim(); + const display_name = String(data.get('display_name') ?? '').trim(); + const app_url = String(data.get('app_url') ?? '').trim(); + const is_self_hosted = data.get('is_self_hosted') === 'on'; + + // Valores para repoblar el formulario si algo falla. + const values = { + name, + slug, + contact_email, + contact_name, + contact_phone, + display_name, + app_url, + is_self_hosted + }; + + const validationError = validateTenantForm({ name, slug, contact_email }); + if (validationError) return fail(422, { error: validationError, values }); + + // Solo se envían los campos opcionales con valor, para no mandar strings vacíos. + const payload: CreateTenantInput = { + name, + slug, + contact_email, + is_self_hosted, + ...(contact_name ? { contact_name } : {}), + ...(contact_phone ? { contact_phone } : {}), + ...(display_name ? { display_name } : {}), + ...(app_url ? { app_url } : {}) + }; + + const res = await createWorkspaceTenant(accessToken, fetch, payload); + if (!res.ok) { + return fail(res.forbidden ? 403 : 422, { error: res.error, values }); + } + + return { success: true, tenantName: res.data.name, tenantSlug: res.data.slug }; + } +}; diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte b/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte new file mode 100644 index 0000000..99c3399 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.svelte @@ -0,0 +1,180 @@ + + +
+
+

+ Organizaciones del workspace +

+

+ Da de alta un cliente nuevo (tenant). Se crea su realm en Keycloak y su licencia base. +

+
+ + {#if data.forbidden} + + + +
+

Requiere permisos de administrador del workspace

+

+ El alta de organizaciones solo está disponible para administradores del Hub + (hub_admin). Tu usuario actual no tiene ese rol. +

+
+
+
+ {:else} + {#if data.loadError} +
+ No se pudo consultar el workspace: {data.loadError} +
+ {/if} + + + + + Nueva organización + El slug identifica al tenant; no se puede cambiar después. + + +
{ + submitting = true; + return async ({ result, update }) => { + submitting = false; + if (result.type === 'success') { + toast.success(`Organización creada: ${result.data?.tenantName ?? ''}`); + await update({ reset: true }); + } else if (result.type === 'failure') { + toast.error(String(result.data?.error ?? 'No se pudo crear la organización')); + await update({ reset: false }); + } else { + await update(); + } + }; + }} + > +
+ + + + + + + + +
+ +
+ +
+
+
+
+ + + + + Organizaciones ({data.tenants.length}) + Tenants activos en el workspace. + + + {#if data.tenants.length === 0} +

+ Aún no hay organizaciones registradas. +

+ {:else} +
+ + + + + + + + + + + + {#each data.tenants as t (t.id)} + + + + + + + + {/each} + +
NombreSlugContactoEstatusLicencia
{t.display_name || t.name}{t.slug}{t.contact_email} + + {t.status} + + {t.has_license ? 'Sí' : '—'}
+
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts new file mode 100644 index 0000000..f3bc5a1 --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts @@ -0,0 +1,63 @@ +import { fail } from '@sveltejs/kit'; +import type { PageServerLoad, Actions } from './$types'; +import { getAuthTokens } from '$lib/server/api'; +import { listWorkspaceTenants, createWorkspaceInvite } from '$lib/server/workspace-provision'; +import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared'; + +export const load: PageServerLoad = async ({ cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) { + return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; + } + + // Los hub_admin pueden listar todos los tenants (dropdown). Un admin de tenant + // no puede listarlos (403) pero sí puede invitar a SU tenant escribiendo el slug. + const res = await listWorkspaceTenants(accessToken, fetch); + if (!res.ok) { + return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; + } + + return { + tenants: res.data.map((t) => ({ slug: t.slug, name: t.display_name || t.name })), + canListTenants: true, + roles: WORKSPACE_INVITE_ROLES + }; +}; + +export const actions: Actions = { + invite: async ({ request, cookies, fetch }) => { + const { accessToken } = getAuthTokens(cookies); + if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); + + const data = await request.formData(); + const email = String(data.get('email') ?? '').trim(); + const tenant_slug = String(data.get('tenant_slug') ?? '') + .trim() + .toLowerCase(); + const role = String(data.get('role') ?? 'user').trim(); + + const values = { email, tenant_slug, role }; + + if (!email || !email.includes('@')) { + return fail(422, { error: 'Ingresa un email válido para invitar.', values }); + } + if (!tenant_slug) { + return fail(422, { error: 'Selecciona (o escribe) el slug de la organización destino.', values }); + } + if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) { + return fail(422, { error: 'Rol inválido.', values }); + } + + const res = await createWorkspaceInvite(accessToken, fetch, { email, tenant_slug, role }); + if (!res.ok) { + return fail(res.forbidden ? 403 : 422, { error: res.error, values }); + } + + return { + success: true, + email: res.data.email, + inviteUrl: res.data.invite_url, + expiresAt: res.data.expires_at + }; + } +}; diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte new file mode 100644 index 0000000..4b156fd --- /dev/null +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte @@ -0,0 +1,152 @@ + + +
+
+

+ Alta de usuarios (invitaciones) +

+

+ Invita a un usuario a una organización del workspace. Recibe un enlace de un solo uso para + fijar su contraseña y activarse. +

+
+ + {#if created} + + +
+ +
+

Invitación enviada a {created.email}

+

+ Si el correo no llega, comparte este enlace directamente: +

+
+ + +
+
+
+
+
+ {/if} + + + + Nueva invitación + + {#if data.canListTenants} + Elige la organización destino y el rol del usuario. + {:else} + Escribe el slug de tu organización y el rol del usuario. + {/if} + + + +
{ + submitting = true; + return async ({ result, update }) => { + submitting = false; + if (result.type === 'success') { + toast.success('Invitación creada'); + await update({ reset: true }); + } else if (result.type === 'failure') { + toast.error(String(result.data?.error ?? 'No se pudo crear la invitación')); + await update({ reset: false }); + } else { + await update(); + } + }; + }} + > +
+ + + + + +
+ +
+ +
+
+
+
+
From 5d1c65f2367400ee5a8be0b6ac30535e911fefb0 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 16 Jul 2026 16:50:35 -0600 Subject: [PATCH 03/40] =?UTF-8?q?feat(auth):=20sesi=C3=B3n=20local=20del?= =?UTF-8?q?=20CRM=20(patr=C3=B3n=20SIWEB)=20para=20eliminar=20el=20bucle?= =?UTF-8?q?=20de=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desacopla la sesión de la app del access token de Keycloak (60s). Tras el SSO/ refresh, el backend emite un JWT de sesión local (HS256) con vida por inactividad (SESSION_IDLE_MINUTES, cap SESSION_MAX_HOURS) y guarda los tokens KC en valkey. La app usa esa sesión local como bearer (cookie access_token); el token KC vive en kc_access_token solo para llamadas directas al Hub. Así el refresh KC contra el Hub solo se intenta al expirar la sesión local, no cada ~60s → se elimina el bucle causado por el bug "Token is not active" del relay. Se RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la sesión termina (401). Sin re-emisión de fallback → sin bypass de revocación. Todo detrás de SESSION_STORE_ENABLED (default False = comportamiento idéntico). Backend: core/local_session.py, core/session_store.py (valkey), verify_token acepta la sesión local, refresh la emite/actualiza; DTOs con session_token/session_id; test unitario de local_session. Frontend: access_token=sesión local, kc_access_token para el Hub; sso/refresh/ silent-refresh/switch-tenant y pantallas de workspace cableadas. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/dto.py | 12 ++ backend/api/v1/modules/core/auth/service.py | 150 +++++++++++++----- backend/core/config.py | 13 ++ backend/core/local_session.py | 94 +++++++++++ backend/core/security.py | 13 ++ backend/core/session_store.py | 111 +++++++++++++ backend/tests/test_local_session.py | 74 +++++++++ frontend/src/lib/server/api.ts | 71 ++++++++- .../auth/silent-refresh/+server.ts | 35 ++-- .../auth/switch-tenant/+server.ts | 6 +- frontend/src/routes/auth/sso/+page.server.ts | 22 ++- .../src/routes/dashboard/+layout.server.ts | 9 +- .../workspace/organizaciones/+page.server.ts | 6 +- .../workspace/usuarios/+page.server.ts | 6 +- 14 files changed, 556 insertions(+), 66 deletions(-) create mode 100644 backend/core/local_session.py create mode 100644 backend/core/session_store.py create mode 100644 backend/tests/test_local_session.py diff --git a/backend/api/v1/modules/core/auth/dto.py b/backend/api/v1/modules/core/auth/dto.py index a9bad09..287d255 100644 --- a/backend/api/v1/modules/core/auth/dto.py +++ b/backend/api/v1/modules/core/auth/dto.py @@ -36,6 +36,12 @@ class TokenResponseDTO(BaseModel): tenant: Optional["TenantInfoDTO"] = None tenant_id: Optional[int] = None tenant_slug: Optional[str] = None + # Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED. + # Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y + # que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token + # de arriba sigue siendo el de Keycloak (para llamadas al Hub). + session_token: Optional[str] = None + session_id: Optional[str] = None class Config: json_schema_extra = { @@ -52,6 +58,12 @@ class RefreshTokenRequestDTO(BaseModel): """DTO para solicitud de refresh token""" refresh_token: str = Field(..., description="Refresh token") + # Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el + # inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el + # refresh del token KC contra el Hub falla ("Token is not active" del relay). + session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)") + # session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser). + session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)") class UserInfoResponseDTO(BaseModel): diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 524db10..040e220 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -213,55 +213,133 @@ class AuthService: logger.error(f"Unexpected login error: {str(e)}") raise HTTPException(status_code=500, detail="Authentication error") + def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]: + """ + Decodifica una sesión local del CRM (HS256) verificando la firma pero + SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims + o None si la firma no valida o no es una sesión local del CRM. + """ + if not session_token: + return None + try: + claims = jwt.decode( + session_token, + settings.SECRET_KEY, + algorithms=["HS256"], + options={"verify_exp": False}, + ) + except JWTError: + return None + if not claims.get("crm_session") or claims.get("source") != "local": + return None + return claims + + def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]: + """Construye los claims de la sesión local a partir de la respuesta KC del Hub.""" + kc_claims = self._decode_kc_user_from_token(data.get("access_token", "")) + claims: Dict[str, Any] = dict(kc_claims) + # tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token + if data.get("tenant_id") is not None: + claims["tenant_id"] = data.get("tenant_id") + if data.get("tenant_slug") is not None: + claims["tenant_slug"] = data.get("tenant_slug") + return claims + async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO: """ - Refresca el access token usando el Hub + Refresca la sesión. + + - Intenta el refresh del token KC contra el Hub (comportamiento histórico). + - Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM + (patrón SIWEB) que la app usa como bearer y que dura por inactividad, de + modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s). + - Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se + RESPETA la revocación central de Keycloak (sin re-emisión de fallback). """ + from datetime import datetime, timezone + + session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False)) + prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None + prev_sst = prev_claims.get("sst") if prev_claims else None + prev_session_id = refresh_data.session_id if session_enabled else None + + # Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que + # mande el cliente (puede estar desactualizado). Fail-silent. + kc_refresh = refresh_data.refresh_token + if session_enabled and prev_session_id: + from core import session_store + + sess = session_store.get_session(prev_session_id) + if sess and sess.get("refresh_token"): + kc_refresh = sess["refresh_token"] + + # ── Intento de refresh del token KC contra el Hub ──────────────────────── + kc_ok = False + data: Optional[Dict[str, Any]] = None try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{settings.HUB_URL}api/v1/auth/refresh", - json=refresh_data.model_dump() + json={"refresh_token": kc_refresh}, ) - - if response.status_code == 200: + kc_ok = response.status_code == 200 + if kc_ok: data = response.json() - from core.workspace_profile_sync import sync_workspace_profile_for_user - from core.workspace_profile_client import WorkspaceProfileClient + else: + logger.warning("Hub rechazó el refresh (status %s)", response.status_code) + except Exception as exc: + logger.warning("Hub inalcanzable en refresh: %s", exc) + kc_ok = False - workspace_profile = None - try: - workspace_profile = await WorkspaceProfileClient().get_me( - data.get("access_token", "") - ) - except Exception as exc: - logger.warning( - "workspace_profile_sync_failed", - extra={ - "event": "workspace_profile_sync_failed", - "phase": "refresh", - "error": str(exc), - }, - ) - workspace_profile = None + # ── Camino feliz: el Hub renovó el token KC ────────────────────────────── + if kc_ok and data is not None: + from core.workspace_profile_sync import sync_workspace_profile_for_user + from core.workspace_profile_client import WorkspaceProfileClient - await sync_workspace_profile_for_user( - self.db, - access_token=data.get("access_token"), - keycloak_user_id=(workspace_profile or {}).get("sub") - or data.get("sub") - or data.get("user_id"), - tenant_id=data.get("tenant_id"), - workspace_profile=workspace_profile, - force=True, + workspace_profile = None + try: + workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", "")) + except Exception as exc: + logger.warning( + "workspace_profile_sync_failed", + extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)}, ) - return TokenResponseDTO(**data) - - raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + workspace_profile = None - except Exception as e: - logger.error(f"Token refresh error: {str(e)}") - raise HTTPException(status_code=500, detail="Token refresh error") + await sync_workspace_profile_for_user( + self.db, + access_token=data.get("access_token"), + keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"), + tenant_id=data.get("tenant_id"), + workspace_profile=workspace_profile, + force=True, + ) + + resp = TokenResponseDTO(**data) + + if session_enabled: + from core import local_session, session_store + + start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp()) + claims = self._session_claims_from_kc(data) + new_access = data.get("access_token", "") + new_refresh = data.get("refresh_token", "") + # Reutiliza la sesión de valkey si ya existía; si no, la crea. + if prev_session_id and session_store.get_session(prev_session_id): + session_store.update_session_tokens(prev_session_id, new_access, new_refresh) + resp.session_id = prev_session_id + else: + resp.session_id = session_store.create_session(new_access, new_refresh, start) + resp.session_token = local_session.mint_session_token(claims, session_start=start) + + return resp + + # El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación + # central de Keycloak (no hay re-emisión local de fallback). El usuario + # re-entra por el App Launcher. La sesión local de larga duración evita el + # bucle: el refresh solo se intenta al expirar la sesión local por + # inactividad (idle), no cada ~60s como con el token KC crudo. + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") async def get_user_info(self, access_token: str) -> UserInfoResponseDTO: """ diff --git a/backend/core/config.py b/backend/core/config.py index ff4be27..7582cb5 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -42,6 +42,19 @@ class Settings(BaseSettings): PERMISSION_CACHE_ENABLED: bool = True PERMISSION_CACHE_TTL_SECONDS: int = 300 + # Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del + # token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite + # una sesión local firmada (HS256) con vida por inactividad (idle) y cap + # absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar + # la sesión local (no cada ~60s), lo que elimina el bucle de login. + # + # SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la + # sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback: + # con SESSION_STORE_ENABLED=False el comportamiento no cambia. + SESSION_STORE_ENABLED: bool = False + SESSION_IDLE_MINUTES: int = 30 + SESSION_MAX_HOURS: int = 10 + # Synchronization SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production" CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/" diff --git a/backend/core/local_session.py b/backend/core/local_session.py new file mode 100644 index 0000000..48e8061 --- /dev/null +++ b/backend/core/local_session.py @@ -0,0 +1,94 @@ +""" +Sesión local del CRM (patrón SIWEB). + +Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que +transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la +app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante +su ventana de inactividad, de modo que el refresh del token KC solo se intenta al +expirar la sesión local — no cada ~60s. Esto elimina el bucle de login. + +Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina +(no hay re-emisión de fallback). + +Marcadores del token: + - source: "local" + crm_session: True → distingue de tokens KC (RS256) y del + token dev-local (dev_local: True). + - sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap). + - exp → sliding por inactividad (idle); se + re-emite en cada refresh mientras no se supere el cap. + +Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07). +Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto +(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey. +""" + +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from jose import JWTError, jwt + +from core.config import settings + +# Claims de identidad que se propagan del token KC a la sesión local. +_IDENTITY_CLAIMS = ( + "sub", "email", "preferred_username", "username", "name", + "given_name", "family_name", "first_name", "last_name", + "tenant_id", "tenant_slug", "roles", "permissions", + "is_hub_admin", "avatar_url", +) + + +def _now_epoch() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str: + """ + Emite un JWT de sesión local a partir de los claims (verificados) del usuario. + `session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si + no se provee, se usa el momento actual (sesión nueva). + """ + now = _now_epoch() + sst = int(session_start) if session_start else now + + payload: Dict[str, Any] = { + k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None + } + payload.update({ + "source": "local", + "crm_session": True, + "sst": sst, + "iat": now, + "exp": now + settings.SESSION_IDLE_MINUTES * 60, + }) + return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256") + + +def verify_session_token(token: str) -> Optional[Dict[str, Any]]: + """ + Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por + inactividad y no superó el cap absoluto de vida; None en cualquier otro caso. + Nunca lanza (para poder encadenar con la validación contra el Hub). + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + except JWTError: + return None + + # Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local). + if not payload.get("crm_session") or payload.get("source") != "local": + return None + + # Cap absoluto de vida de sesión (independiente del sliding por idle). + sst = payload.get("sst") + if isinstance(sst, (int, float)): + if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600: + return None + + return payload + + +def session_start_of(payload: Dict[str, Any]) -> Optional[int]: + """Extrae el epoch de inicio de sesión (sst) de un payload de sesión local.""" + sst = payload.get("sst") + return int(sst) if isinstance(sst, (int, float)) else None diff --git a/backend/core/security.py b/backend/core/security.py index cb188c9..9127316 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -47,6 +47,19 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str, if cache_key in token_cache: return token_cache[cache_key] + # Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada + # (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh + # del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que + # elimina el bucle de login. verify_session_token retorna None para tokens KC + # (RS256), así que no interfiere con el flujo normal. + if settings.SESSION_STORE_ENABLED: + from core.local_session import verify_session_token + + local_claims = verify_session_token(token) + if local_claims is not None: + token_cache[cache_key] = local_claims + return local_claims + # Shortcut para tokens de desarrollo local if settings.DEV_LOCAL_AUTH: try: diff --git a/backend/core/session_store.py b/backend/core/session_store.py new file mode 100644 index 0000000..e54e4fd --- /dev/null +++ b/backend/core/session_store.py @@ -0,0 +1,111 @@ +""" +Store de sesión en Valkey/Redis (patrón SIWEB). + +Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por +un session_id opaco. La app usa la sesión local firmada (ver core.local_session) +para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub +(provisioning, my-apps, my-tenants), refrescándolos best-effort. + +Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op +y la sesión local firmada sigue sosteniendo la app. +""" + +import json +import logging +import uuid +from typing import Optional + +from core.config import settings + +try: + import redis # type: ignore +except Exception: # pragma: no cover - redis es opcional en algunos entornos + redis = None # type: ignore + +logger = logging.getLogger(__name__) + +_KEY_PREFIX = "crm:session:" +_client = None + + +def _get_client(): + """Cliente Redis/Valkey compartido (perezoso). None si no está disponible.""" + global _client + if redis is None: + return None + if _client is None: + try: + _client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True) + except Exception as exc: + logger.warning("session_store_init_failed: %s", exc) + return None + return _client + + +def _ttl_seconds() -> int: + # La sesión en valkey vive como máximo lo que la vida absoluta de la sesión. + return settings.SESSION_MAX_HOURS * 3600 + + +def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]: + """Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está).""" + client = _get_client() + if client is None: + return None + session_id = str(uuid.uuid4()) + data = json.dumps({ + "access_token": access_token, + "refresh_token": refresh_token or "", + "sst": int(session_start), + }) + try: + client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data) + return session_id + except Exception as exc: + logger.warning("session_store_create_failed: %s", exc) + return None + + +def get_session(session_id: str) -> Optional[dict]: + """Devuelve {access_token, refresh_token, sst} de la sesión, o None.""" + client = _get_client() + if client is None or not session_id: + return None + try: + raw = client.get(f"{_KEY_PREFIX}{session_id}") + return json.loads(raw) if raw else None + except Exception as exc: + logger.warning("session_store_get_failed: %s", exc) + return None + + +def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None: + """Actualiza los tokens KC de una sesión existente conservando su TTL y su sst.""" + client = _get_client() + if client is None or not session_id: + return + try: + key = f"{_KEY_PREFIX}{session_id}" + ttl = client.ttl(key) + if ttl and ttl > 0: + existing = client.get(key) + sst = json.loads(existing).get("sst") if existing else None + data = json.dumps({ + "access_token": access_token, + "refresh_token": refresh_token or "", + "sst": sst, + }) + client.setex(key, ttl, data) + except Exception as exc: + logger.warning("session_store_update_failed: %s", exc) + + +def delete_session(session_id: str) -> None: + """Elimina la sesión (logout). Fail-silent.""" + client = _get_client() + if client is None or not session_id: + return + try: + client.delete(f"{_KEY_PREFIX}{session_id}") + except Exception as exc: + logger.warning("session_store_delete_failed: %s", exc) diff --git a/backend/tests/test_local_session.py b/backend/tests/test_local_session.py new file mode 100644 index 0000000..2781bc7 --- /dev/null +++ b/backend/tests/test_local_session.py @@ -0,0 +1,74 @@ +""" +Pruebas de la sesión local del CRM (patrón SIWEB) — core.local_session. + +Lógica pura (firma HS256 + claims); no requiere BD ni valkey. +""" + +from datetime import datetime, timezone + +from jose import jwt + +from core.config import settings +from core.local_session import mint_session_token, verify_session_token, session_start_of + + +def _now() -> int: + return int(datetime.now(timezone.utc).timestamp()) + + +def test_round_trip_conserva_identidad(): + claims = { + "sub": "kc-user-123", + "email": "user@example.com", + "tenant_id": 11, + "tenant_slug": "aduanasoft", + "is_hub_admin": True, + } + token = mint_session_token(claims) + out = verify_session_token(token) + + assert out is not None + assert out["sub"] == "kc-user-123" + assert out["tenant_id"] == 11 + assert out["tenant_slug"] == "aduanasoft" + assert out["is_hub_admin"] is True + assert out["source"] == "local" + assert out["crm_session"] is True + assert isinstance(out["sst"], int) + + +def test_cap_absoluto_rechaza_sesion_vieja(): + # session_start más allá del cap absoluto → verify debe rechazar aunque no expiró por idle. + old_start = _now() - (settings.SESSION_MAX_HOURS * 3600 + 120) + token = mint_session_token({"sub": "x"}, session_start=old_start) + assert verify_session_token(token) is None + + +def test_preserva_session_start(): + start = _now() - 60 + token = mint_session_token({"sub": "x"}, session_start=start) + out = verify_session_token(token) + assert out is not None + assert session_start_of(out) == start + + +def test_firma_alterada_se_rechaza(): + token = mint_session_token({"sub": "x"}) + # Alterar el último carácter de la firma invalida el token. + tampered = token[:-1] + ("A" if token[-1] != "A" else "B") + assert verify_session_token(tampered) is None + + +def test_token_no_crm_se_rechaza(): + # Un HS256 válido pero SIN los marcadores de sesión local no debe aceptarse. + other = jwt.encode( + {"sub": "x", "exp": _now() + 600}, + settings.SECRET_KEY, + algorithm="HS256", + ) + assert verify_session_token(other) is None + + +def test_token_basura_se_rechaza(): + assert verify_session_token("no-es-un-jwt") is None + assert verify_session_token("") is None diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index 2c39118..dd148b5 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -82,6 +82,60 @@ export function clearAuthTokens(cookies: Cookies) { cookies.delete('id_token', { path: '/' }); cookies.delete('active_company_id', { path: '/' }); cookies.delete('active_system', { path: '/' }); + // Sesión local del CRM (patrón SIWEB) + cookies.delete('kc_access_token', { path: '/' }); + cookies.delete('crm_sid', { path: '/' }); +} + +/** + * Token de Keycloak para llamadas DIRECTAS al Hub (my-apps, my-tenants, + * provisioning). Con el patrón de sesión local (SIWEB) el `access_token` guarda + * la sesión local del CRM, así que el token KC vive en su propia cookie + * `kc_access_token`. Fallback a `access_token` cuando el patrón está apagado + * (kc_access_token ausente) → comportamiento histórico intacto. + */ +export function getKcAccessToken(cookies: Cookies): string | null { + return cookies.get('kc_access_token') ?? getAccessTokenFromCookies(cookies); +} + +/** + * Aplica a las cookies la respuesta de /v1/auth/refresh considerando la sesión + * local del CRM (patrón SIWEB): + * - Con `session_token`: `access_token` = sesión local (bearer de la app), + * `kc_access_token` = token KC (para el Hub), `crm_sid` = id de sesión valkey. + * - Sin él: comportamiento histórico (`access_token` = token KC). + * Devuelve el token que la app debe usar para reintentar (la sesión local si aplica). + */ +export function applyRefreshedTokens( + cookies: Cookies, + data: { access_token?: string; refresh_token?: string; session_token?: string; session_id?: string } +): string | null { + const secure = isSecureContext(); + if (data.session_token) { + setAccessTokenCookies(cookies, data.session_token, { secure, maxAge: 60 * 60 * 24 * 7 }); + // access_token KC vacío = fallback sin token fresco → conservar el actual. + if (data.access_token) { + cookies.set('kc_access_token', data.access_token, { + path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 7 + }); + } + if (data.session_id) { + cookies.set('crm_sid', data.session_id, { + path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30 + }); + } + if (data.refresh_token) { + cookies.set('refresh_token', data.refresh_token, { + path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30 + }); + } + return data.session_token; + } + if (data.access_token) { + setAuthTokens(cookies, data.access_token, data.refresh_token); + return data.access_token; + } + return null; } /** @@ -113,12 +167,21 @@ export async function refreshAccessToken( try { const baseUrl = getServerApiUrl(); + // Sesión local actual del CRM (patrón SIWEB): se envía para preservar el + // inicio de sesión (cap absoluto) y permitir el re-emitido de fallback + // cuando el refresh del token KC contra el Hub falla. + const currentSession = getAccessTokenFromCookies(cookies); + const sessionId = cookies.get('crm_sid'); const response = await fetch(`${baseUrl}v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) + body: JSON.stringify({ + refresh_token: refreshToken, + ...(currentSession ? { session_token: currentSession } : {}), + ...(sessionId ? { session_id: sessionId } : {}) + }) }); if (!response.ok) { @@ -127,10 +190,8 @@ export async function refreshAccessToken( const data = await response.json(); - // Actualizar las cookies con los nuevos tokens - setAuthTokens(cookies, data.access_token, data.refresh_token); - - return data.access_token; + // Actualiza las cookies teniendo en cuenta la sesión local (o histórico si no aplica). + return applyRefreshedTokens(cookies, data); } catch (error) { console.error('🔄 [API] Error al refrescar token:', error); return null; diff --git a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts index 3d9736d..bc7dad7 100644 --- a/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/silent-refresh/+server.ts @@ -14,8 +14,8 @@ import { json } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { getServerApiUrl, setAuthTokens } from '$lib/server/api'; -import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; +import { getServerApiUrl, applyRefreshedTokens, clearAuthTokens } from '$lib/server/api'; +import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie'; export const POST = async ({ cookies, fetch }: RequestEvent) => { const refreshToken = cookies.get('refresh_token'); @@ -27,34 +27,43 @@ export const POST = async ({ cookies, fetch }: RequestEvent) => { try { const baseUrl = getServerApiUrl(); + // Sesión local actual del CRM (patrón SIWEB): se reenvía para preservar el + // inicio de sesión y permitir el re-emitido de fallback cuando el refresh KC falla. + const currentSession = getAccessTokenFromCookies(cookies); + const sessionId = cookies.get('crm_sid'); + const response = await fetch(`${baseUrl}v1/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: refreshToken }) + body: JSON.stringify({ + refresh_token: refreshToken, + ...(currentSession ? { session_token: currentSession } : {}), + ...(sessionId ? { session_id: sessionId } : {}) + }) }); if (!response.ok) { - // El refresh token expiró o fue invalidado por Keycloak (sesión terminada). - // Limpiar las cookies para que el servidor redirigir al login en la siguiente carga. - cookies.delete('refresh_token', { path: '/' }); - clearAccessTokenCookies(cookies); - cookies.delete('active_company_id', { path: '/' }); + // El refresh falló y no hubo sesión local que re-emitir (cap superado o + // patrón apagado). Limpiar cookies para que el server redirija al login. + clearAuthTokens(cookies); const status = response.status === 401 ? 401 : 400; return json({ error: 'Refresh token expired or invalid' }, { status }); } const data = (await response.json()) as { - access_token: string; + access_token?: string; refresh_token?: string; + session_token?: string; + session_id?: string; expires_in?: number; }; - // Actualizar las cookies HttpOnly con los nuevos tokens - setAuthTokens(cookies, data.access_token, data.refresh_token); + // Actualiza cookies considerando la sesión local; devuelve el bearer de la app. + const appToken = applyRefreshedTokens(cookies, data); - // Devolver solo el access_token al cliente - return json({ access_token: data.access_token }); + // Devolver solo el token de app al cliente (sesión local si aplica, o KC). + return json({ access_token: appToken ?? data.access_token ?? '' }); } catch (error) { console.error('[silent-refresh] Error inesperado:', error); return json({ error: 'Internal server error' }, { status: 500 }); diff --git a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts index 143fe04..b899aa9 100644 --- a/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts +++ b/frontend/src/routes/api-sveltekit/auth/switch-tenant/+server.ts @@ -9,7 +9,7 @@ import { json } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import type { RequestEvent } from '@sveltejs/kit'; -import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api'; +import { getServerApiUrl, getAuthTokens, getKcAccessToken, setAuthTokens } from '$lib/server/api'; export const POST = async ({ request, cookies, fetch }: RequestEvent) => { const body = await request.json(); @@ -28,9 +28,11 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => { // Modo SSO relay: validar acceso vía Hub y actualizar cookie de override if (tenant_id) { try { + // Validación contra el Hub → token KC (el access_token puede ser la sesión local). + const kcToken = getKcAccessToken(cookies) ?? accessToken; const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { - headers: { 'Authorization': `Bearer ${accessToken}` }, + headers: { 'Authorization': `Bearer ${kcToken}` }, }); if (!tenantsRes.ok) { return json({ error: 'Could not validate tenant access' }, { status: 403 }); diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index e56cdaf..9b7b62c 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -134,12 +134,32 @@ export const load: PageServerLoad = async ({ url, cookies }) => { const isProduction = isSecureContext(); console.log('[SSO] ORIGIN-based secure context:', isProduction); + // Sesión local del CRM (patrón SIWEB): si el refresh proactivo devolvió una + // sesión local firmada, el access_token guarda ESA sesión (bearer de la app, + // sobrevive aunque el refresh KC del Hub falle) y el token KC va a su propia + // cookie kc_access_token (solo para llamadas directas al Hub). Si no vino + // (flag apagado), comportamiento histórico: access_token = token KC. + const sessionToken = typeof tokens.session_token === 'string' ? tokens.session_token : null; + const kcAccessToken = tokens.access_token as string; + // access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB - setAccessTokenCookies(cookies, tokens.access_token as string, { + setAccessTokenCookies(cookies, sessionToken ?? kcAccessToken, { secure: isProduction, maxAge: 60 * 60 * 24 * 7, }); + if (sessionToken) { + // kc_access_token — HttpOnly; solo el server lo usa para llamar al Hub. + cookies.set('kc_access_token', kcAccessToken, { + path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7, + }); + if (typeof tokens.session_id === 'string') { + cookies.set('crm_sid', tokens.session_id, { + path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30, + }); + } + } + // refresh_token — HttpOnly (never exposed to JS) if (typeof tokens.refresh_token === 'string') { cookies.set('refresh_token', tokens.refresh_token, { diff --git a/frontend/src/routes/dashboard/+layout.server.ts b/frontend/src/routes/dashboard/+layout.server.ts index a3f98a9..9a38390 100644 --- a/frontend/src/routes/dashboard/+layout.server.ts +++ b/frontend/src/routes/dashboard/+layout.server.ts @@ -3,6 +3,7 @@ import type { LayoutServerLoad } from './$types'; import { validateAuth, getAuthTokens, + getKcAccessToken, getUserCompanies, clearAuthTokens } from '$lib/server/api'; @@ -30,12 +31,15 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null }; if (!DEV_LOCAL_AUTH) { + // Llamadas DIRECTAS al Hub → token KC (con el patrón de sesión local, el + // access_token guarda la sesión local del CRM, no el token de Keycloak). + const kcToken = getKcAccessToken(cookies) ?? accessToken; try { const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, ''); const tenantOverride = cookies.get('sso_tenant_id'); const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, { headers: { - 'Authorization': `Bearer ${accessToken}`, + 'Authorization': `Bearer ${kcToken}`, ...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {}) } }); @@ -46,8 +50,7 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => { // No bloquear el dashboard si falla la carga de tenants } - const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken; - myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id')); + myApps = await fetchMyApps(kcToken, fetch, cookies.get('sso_tenant_id')); } return { diff --git a/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts index aab020a..e0533c1 100644 --- a/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts +++ b/frontend/src/routes/dashboard/workspace/organizaciones/+page.server.ts @@ -1,6 +1,6 @@ import { fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; -import { getAuthTokens } from '$lib/server/api'; +import { getKcAccessToken } from '$lib/server/api'; import { listWorkspaceTenants, createWorkspaceTenant, @@ -9,7 +9,7 @@ import { import { validateTenantForm } from '$lib/server/workspace-provision.shared'; export const load: PageServerLoad = async ({ cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) { return { tenants: [], forbidden: true, loadError: null }; } @@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, fetch }) => { export const actions: Actions = { create: async ({ request, cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); const data = await request.formData(); diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts index f3bc5a1..76f63be 100644 --- a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts @@ -1,11 +1,11 @@ import { fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; -import { getAuthTokens } from '$lib/server/api'; +import { getKcAccessToken } from '$lib/server/api'; import { listWorkspaceTenants, createWorkspaceInvite } from '$lib/server/workspace-provision'; import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared'; export const load: PageServerLoad = async ({ cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) { return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; } @@ -26,7 +26,7 @@ export const load: PageServerLoad = async ({ cookies, fetch }) => { export const actions: Actions = { invite: async ({ request, cookies, fetch }) => { - const { accessToken } = getAuthTokens(cookies); + const accessToken = getKcAccessToken(cookies); if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' }); const data = await request.formData(); From 579c6e1f19dc014b160f8ba22da289e191544aee Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 16 Jul 2026 17:03:01 -0600 Subject: [PATCH 04/40] =?UTF-8?q?chore(deploy):=20activar=20sesi=C3=B3n=20?= =?UTF-8?q?local=20(patr=C3=B3n=20SIWEB)=20en=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasa SESSION_STORE_ENABLED/IDLE/MAX al backend vía environment del override (el contenedor no lee .env). SESSION_STORE_ENABLED=false revierte el patrón. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/docker-compose.testing.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.testing.yml b/deploy/docker-compose.testing.yml index ee5adfb..838cdd5 100644 --- a/deploy/docker-compose.testing.yml +++ b/deploy/docker-compose.testing.yml @@ -5,11 +5,26 @@ # docker compose -f docker-compose.yml -f deploy/docker-compose.testing.yml up -d --build services: backend: + # DNS por-contenedor: el resolver del host no es alcanzable desde los contenedores; + # el backend debe resolver workspace.aduanasoft.com (Hub) en runtime. + dns: + - "8.8.8.8" + - "1.1.1.1" ports: !override - "127.0.0.1:8000:8000" + # Sesión local del CRM (patrón SIWEB). El backend toma su config de esta lista + # (no lee el .env dentro del contenedor), así que los flags van aquí. Valores + # desde el .env por sustitución. SESSION_STORE_ENABLED=false revierte al comportamiento previo. + environment: + - SESSION_STORE_ENABLED=${SESSION_STORE_ENABLED:-true} + - SESSION_IDLE_MINUTES=${SESSION_IDLE_MINUTES:-30} + - SESSION_MAX_HOURS=${SESSION_MAX_HOURS:-10} command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"] frontend: + dns: + - "8.8.8.8" + - "1.1.1.1" build: context: ./frontend dockerfile: Dockerfile.prod @@ -21,8 +36,11 @@ services: VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-aduanasoft} environment: - NODE_ENV=production + # El callback OIDC (/auth/callback) intercambia el código por tokens con el + # secret del client confidencial 'aduanasoft'. El compose base no lo pasa al frontend. + - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-} volumes: !override [] - command: !override ["pnpm", "start"] + command: !override ["node", "build/index.js"] ports: !override - "127.0.0.1:5173:5173" @@ -31,3 +49,10 @@ services: minio: ports: !override [] + +# En el server no existe el Hub local: app-hub deja de ser red externa y se crea local. +# El CRM alcanza el Hub por su URL pública (workspace.aduanasoft.com), no por esta red. +networks: + app-hub: + external: false + driver: bridge From 223395b43053fffec1c8e75f4cfd65d9335ffe7b Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 16 Jul 2026 18:05:44 -0600 Subject: [PATCH 05/40] =?UTF-8?q?feat(core):=20compa=C3=B1=C3=ADa=20por=20?= =?UTF-8?q?tenant=20(1:1)=20en=20my-companies=20para=20desbloquear=20el=20?= =?UTF-8?q?dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_my_companies devolvía [] (STUB) → sin compañía activa el CRM bloqueaba todo. Modelo: una empresa por tenant (company_id = tenant_id, como el agente de carga). Asegura el vínculo user↔tenant↔company; los permisos se resuelven en /permissions/me (bootstrap de super_admin al primer usuario). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/routes.py | 46 ++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index f566340..7473724 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -402,10 +402,16 @@ async def dev_login(): @router.get("/my-companies") async def get_my_companies( current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), ): """ Retorna las compañías accesibles para el usuario actual. - STUB: implementa con tu modelo de compañías. + + Modelo del CRM: una compañía por tenant (1:1) — el ``company_id`` coincide con + el ``tenant_id``. Cada agente de carga (tenant) opera como una empresa. Se + garantiza el vínculo usuario↔tenant↔company; los permisos de la empresa se + resuelven en ``/permissions/me`` (bootstrap de super_admin al primer usuario). + En dev-local retorna una compañía ficticia para que el dashboard funcione. """ from core.config import settings @@ -415,8 +421,42 @@ async def get_my_companies( "id": settings.DEV_LOCAL_AUTH_COMPANY_ID, "name": "Empresa Dev Local", "tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID, + "rfc": None, + "logo": None, "is_active": True, }] - # Implementa aquí la consulta real a tu tabla de compañías. - return [] + from core.security import ( + resolve_effective_tenant_id_from_user, + _ensure_user_tenant_for_company, + ) + from api.v1.modules.core.tenants.models import Tenant + + tenant_id = resolve_effective_tenant_id_from_user(current_user) + if not tenant_id: + return [] + + tenant = db.query(Tenant).filter(Tenant.id == int(tenant_id)).first() + name = ( + (tenant.name if tenant else None) + or current_user.get("tenant_slug") + or "Mi empresa" + ) + + # Garantizar el vínculo usuario↔tenant↔company (company_id = tenant_id). + user_id = current_user.get("sub") or current_user.get("id") + if user_id: + try: + _ensure_user_tenant_for_company(db, str(user_id), int(tenant_id), int(tenant_id)) + except Exception as exc: + logger = __import__("logging").getLogger(__name__) + logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc) + + return [{ + "id": int(tenant_id), + "name": name, + "tenant_id": int(tenant_id), + "rfc": None, + "logo": None, + "is_active": True, + }] From b76d42be830d2b163c67d94f9ab4402e2629bf3c Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 09:01:10 -0600 Subject: [PATCH 06/40] =?UTF-8?q?refactor(auth):=20login=20100%=20v=C3=ADa?= =?UTF-8?q?=20Workspace/Hub=20=E2=80=94=20sin=20comunicaci=C3=B3n=20direct?= =?UTF-8?q?a=20a=20Keycloak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patrón SIWEB: el CRM ya NO habla directo a Keycloak. Se elimina el flujo OIDC paralelo (que causaba el bucle e "issuer mismatch"): - workspace-auth.ts: se quitan builders de URL de KC (authorization/login/logout) y getPublicKeycloakBaseUrl/Realm/ClientId. getWorkspaceLoginUrl ya no lleva return_to a /login?sso_verified (evita el rebote sin sesión = bucle). - /login: sso_verified=1 → /dashboard; sin sesión → App Launcher del Workspace (relay). Se eliminan redirectToKeycloakAuthorization/Login. - /auth/callback: obsoleto — ya no intercambia code con KC; redirige a /dashboard. - /logout y /auth/post-logout: limpian sesión local y vuelven al Workspace (el logout completo del Hub/KC se hace desde el Workspace). - /join: usa redirectToWorkspaceLogin en vez de KC. - lib/auth.ts: initAuth ya no inicializa keycloak-js en el browser; getToken y refreshAccessToken operan por cookie + silent-refresh (backend → Hub). Login = solo App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/auth.ts | 8 +- frontend/src/lib/server/workspace-auth.ts | 168 ++++-------------- .../src/routes/auth/callback/+page.server.ts | 141 ++------------- .../src/routes/auth/post-logout/+server.ts | 11 +- frontend/src/routes/join/+page.server.ts | 6 +- frontend/src/routes/login/+page.server.ts | 33 +--- frontend/src/routes/logout/+server.ts | 37 ++-- 7 files changed, 79 insertions(+), 325 deletions(-) diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 0e764a7..666e621 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -407,10 +407,12 @@ export const initAuth = async (): Promise => { return true; } - // Sin token local, intentar Keycloak JS (flujo SSO) - const authenticated = await initKeycloak(); + // Sin token local: no hay sesión en el cliente. El login entra SIEMPRE por + // el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange); + // el CRM NO inicializa Keycloak en el browser. Si no hay token, el layout + // del servidor reenvía al Workspace. authStore.setLoading(false); - return authenticated; + return false; } catch (err) { console.error('[auth] Error en initAuth:', err); authStore.setLoading(false); diff --git a/frontend/src/lib/server/workspace-auth.ts b/frontend/src/lib/server/workspace-auth.ts index cc94854..8e0dee6 100644 --- a/frontend/src/lib/server/workspace-auth.ts +++ b/frontend/src/lib/server/workspace-auth.ts @@ -5,9 +5,14 @@ const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com'; const RETURN_PATH_COOKIE = 'workspace_return_path'; /** - * Returns true only when the public-facing URL uses HTTPS. - * Use this for cookie `secure` flag instead of NODE_ENV so that - * cookies work on HTTP LAN dev environments (e.g. 192.168.x.x). + * Autenticación 100% vía el Hub/Workspace (patrón SIWEB). El CRM NUNCA habla + * directo a Keycloak: el login entra por el App Launcher del workspace (relay + * → /auth/sso → Hub /sso-exchange) y el resto de auth va por la API del Hub. + */ + +/** + * True solo cuando la URL pública usa HTTPS. Se usa para el flag `secure` de las + * cookies (en vez de NODE_ENV) para que funcionen en dev HTTP LAN (192.168.x.x). */ export function isSecureContext(): boolean { const origin = (env.ORIGIN || process.env.ORIGIN || '').trim(); @@ -20,9 +25,8 @@ function stripTrailingSlashes(value: string): string { } /** - * Detecta si una URL apunta a un host que solo es accesible localmente: - * localhost, 127.0.0.1, IPs de red LAN/privada y hostnames internos de Docker. - * Estas URLs no son válidas como redirect_uri ni como KC public URL en producción. + * Detecta URLs solo accesibles localmente (localhost, IPs LAN/privadas, hosts + * internos de Docker). No son válidas como URL pública del Workspace. */ function isDevOnlyUrl(rawUrl: string): boolean { try { @@ -60,27 +64,14 @@ export function getWorkspaceBaseUrl(): string { } /** - * Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri seguros. - * - * Problema habitual en producción: SvelteKit deriva `url.origin` de la variable de entorno - * `ORIGIN`. Si el contenedor se despliega con `ORIGIN=http://localhost:5173` (valor del .env - * de dev), todos los redirect_uri generados por el servidor apuntan a localhost. - * - * Esta función: - * 1. Usa `requestOrigin` si ya es una URL pública (no dev-only). - * 2. Si es localhost, busca `SITE_URL` (env var de producción recomendada) como fallback. - * 3. Como último recurso devuelve requestOrigin tal cual (entorno dev genuino). - * - * Var de entorno recomendada en producción: - * SITE_URL=https://mi-app.dominio.com (además de arreglar ORIGIN) + * Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri. + * Corrige el caso donde `ORIGIN` env var apunta a localhost en producción. */ export function resolveSystemBaseUrl(requestOrigin: string): string { if (!isDevOnlyUrl(requestOrigin)) { return stripTrailingSlashes(requestOrigin); } - // requestOrigin es dev-only → ORIGIN env var apunta a localhost en producción. - // Buscar URL pública en env vars adicionales. const candidates = [ (env.SITE_URL || '').trim(), (env.APP_URL || '').trim(), @@ -93,31 +84,17 @@ export function resolveSystemBaseUrl(requestOrigin: string): string { } } - // Entorno dev genuino: devolver requestOrigin tal cual return stripTrailingSlashes(requestOrigin); } -export type WorkspaceLoginUrlOptions = { - /** - * URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que, - * tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps). - * Con `return_to` a Mi Aplicación, el re-login siempre rebotaba a esa app aunque hubiera más. - */ - forPostLogout?: boolean; -}; - -export function getWorkspaceLoginUrl( - systemBaseUrl: string, - options?: WorkspaceLoginUrlOptions -): string { - const workspaceBaseUrl = getWorkspaceBaseUrl(); - if (options?.forPostLogout) { - return `${workspaceBaseUrl}/login`; - } - // return_to includes sso_verified=1 so the workspace preserves it when redirecting - // back, regardless of what additional params the workspace appends. - const loginUrl = `${systemBaseUrl}/login?sso_verified=1`; - return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`; +/** + * URL de login del Workspace. NO lleva `return_to` a Mi Aplicación: el Hub + * muestra el App Launcher y el usuario re-entra al CRM por relay + * (→ /auth/sso?relay=). Así se evita el rebote a /login sin sesión (bucle) y no + * se usa ningún flujo OIDC directo contra Keycloak. + */ +export function getWorkspaceLoginUrl(): string { + return `${getWorkspaceBaseUrl()}/login`; } export function storeReturnPath(cookies: Cookies, path: string): void { @@ -131,26 +108,6 @@ export function storeReturnPath(cookies: Cookies, path: string): void { }); } -export function getPublicKeycloakBaseUrl(): string { - const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim(); - // Si VITE_KEYCLOAK_URL apunta a un host dev-only (localhost, IP LAN, Docker service), - // ignorarlo y derivar la URL del hostname público del Workspace. - // Esto protege contra builds donde el .env de dev llega a producción por error. - if (configuredKeycloakUrl && !isDevOnlyUrl(configuredKeycloakUrl)) { - return stripTrailingSlashes(configuredKeycloakUrl); - } - - return `${getWorkspaceBaseUrl()}/kcauth`; -} - -export function getKeycloakRealm(): string { - return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim(); -} - -export function getKeycloakClientId(): string { - return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'app-frontend').trim(); -} - export function getCleanReturnPath(url: URL): string { const cleanParams = new URLSearchParams(url.searchParams); cleanParams.delete('sso_verified'); @@ -186,68 +143,9 @@ export function clearWorkspaceReturnPath(cookies: Cookies): void { cookies.delete(RETURN_PATH_COOKIE, { path: '/' }); } -export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string { - const keycloakBaseUrl = getPublicKeycloakBaseUrl(); - // resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado - const publicBase = resolveSystemBaseUrl(systemBaseUrl); - const redirectUri = `${publicBase}/auth/callback`; - const state = JSON.stringify({ redirect_url: redirectPath }); - const params = new URLSearchParams({ - client_id: getKeycloakClientId(), - redirect_uri: redirectUri, - response_type: 'code', - scope: 'openid', - prompt: 'none', - state - }); - - return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`; -} - -/** - * Construye URL de login directo en KC sin prompt=none. - * Usa la sesión KC existente si la hay; si no, muestra el form de login. - * Usar cuando se recibe ?redirect= del Hub (rompe el loop Hub↔login). - */ -export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string { - const keycloakBaseUrl = getPublicKeycloakBaseUrl(); - // resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado - const publicBase = resolveSystemBaseUrl(systemBaseUrl); - const redirectUri = `${publicBase}/auth/callback`; - const state = JSON.stringify({ redirect_url: redirectPath }); - const params = new URLSearchParams({ - client_id: getKeycloakClientId(), - redirect_uri: redirectUri, - response_type: 'code', - scope: 'openid', - state - }); - - return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`; -} - -export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never { - // Modo local: nunca salir al workspace, mostrar el login local. - if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') { - throw redirect(303, '/login'); - } - storeWorkspaceReturnPath(cookies, url); - throw redirect(303, getWorkspaceLoginUrl(url.origin)); -} - -export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never { - throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath)); -} - -export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never { - throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath)); -} - /** * URL del Hub FastAPI para llamadas server-to-server (ej. sso-exchange). * No aplica isDevOnlyUrl: las URLs internas de Docker son válidas aquí. - * Lee HUB_BACKEND_URL (override explícito) → INTERNAL_HUB_URL (ya en docker-compose) - * → fallback a URL pública del workspace (vía proxy SvelteKit del Hub). */ export function getHubBackendUrl(): string { const direct = @@ -257,19 +155,15 @@ export function getHubBackendUrl(): string { return getWorkspaceBaseUrl(); } -export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string { - const keycloakBaseUrl = getPublicKeycloakBaseUrl(); - const postLogoutRedirectUri = `${systemBaseUrl}/auth/post-logout`; - const params = new URLSearchParams({ - client_id: getKeycloakClientId(), - post_logout_redirect_uri: postLogoutRedirectUri - }); - - // Con id_token_hint KC acepta cualquier post_logout_redirect_uri sin necesidad - // de que esté registrado explícitamente en el cliente. - if (idTokenHint) { - params.set('id_token_hint', idTokenHint); +/** + * Redirige al login del Workspace (App Launcher). Único punto de entrada de + * login: el CRM no inicia ningún flujo contra Keycloak. + */ +export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never { + // Modo local: nunca salir al workspace, mostrar el login local. + if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') { + throw redirect(303, '/login'); } - - return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`; -} \ No newline at end of file + storeWorkspaceReturnPath(cookies, url); + throw redirect(303, getWorkspaceLoginUrl()); +} diff --git a/frontend/src/routes/auth/callback/+page.server.ts b/frontend/src/routes/auth/callback/+page.server.ts index 42cade0..da1b217 100644 --- a/frontend/src/routes/auth/callback/+page.server.ts +++ b/frontend/src/routes/auth/callback/+page.server.ts @@ -1,132 +1,15 @@ -import { redirect, isRedirect } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; -import { - clearWorkspaceReturnPath, - getWorkspaceLoginUrl, - readWorkspaceReturnPath, - storeReturnPath, -} from '$lib/server/workspace-auth'; -export const load: PageServerLoad = async ({ url, cookies, fetch }) => { - // Obtener el código y state de los query params - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const errorParam = url.searchParams.get('error'); - const errorDescription = url.searchParams.get('error_description'); - - if (errorParam) { - console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription); - // login_required means no KC session exists yet → send to Workspace login. - // Preserve the intended destination through the detour so /login can pick it up. - if (state) { - try { - const stateObj = JSON.parse(state); - const returnPath = stateObj.redirect_url; - if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') { - storeReturnPath(cookies, returnPath); - } - } catch { /* ignore malformed state */ } - } - throw redirect(303, getWorkspaceLoginUrl(url.origin)); - } - - if (!code) { - console.error('❌ [Callback Server] No se recibió código de autorización'); - throw redirect(303, getWorkspaceLoginUrl(url.origin)); - } - - try { - // Intercambiar código por tokens usando el backend de Keycloak - // En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080 - // En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback - const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080'; - const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master'; - const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend'; - const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || ''; - - // La redirect_uri debe coincidir exactamente con la registrada en Keycloak. - // resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque - // ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback). - const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth'); - const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`; - - const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`; - - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - redirect_uri: redirectUri, - client_id: KEYCLOAK_CLIENT_ID, - ...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET }) - }); - - const tokenResponse = await fetch(tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: body.toString() - }); - - if (!tokenResponse.ok) { - const errorData = await tokenResponse.text(); - console.error('❌ [Callback Server] Error al intercambiar código:', errorData); - throw new Error('Error al obtener tokens'); - } - - const tokens = await tokenResponse.json(); - - // Establecer las cookies en el servidor - // access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization) - // refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona) - const { isSecureContext } = await import('$lib/server/workspace-auth'); - const isProduction = isSecureContext(); - - setAccessTokenCookies(cookies, tokens.access_token, { - secure: isProduction, - maxAge: 60 * 60 * 24 * 7 // 7 días - }); - - if (tokens.refresh_token) { - cookies.set('refresh_token', tokens.refresh_token, { - path: '/', - httpOnly: true, // *** HttpOnly: nunca expuesto a JS *** - secure: isProduction, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30 // 30 días - }); - } - - if (tokens.id_token) { - cookies.set('id_token', tokens.id_token, { - path: '/', - httpOnly: true, - secure: isProduction, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 7 - }); - } - - // Obtener la URL de redirección del state o ir al dashboard - let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard'); - if (state) { - try { - const stateObj = JSON.parse(state); - redirectTo = stateObj.redirect_url || '/dashboard'; - } catch (e) { - console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state'); - } - } - - clearWorkspaceReturnPath(cookies); - - // Redirigir a la página de destino - throw redirect(303, redirectTo); - - } catch (err: any) { - if (isRedirect(err)) throw err; - console.error('❌ [Callback Server] Error procesando autenticación:', err); - throw redirect(303, getWorkspaceLoginUrl(url.origin)); - } +/** + * Callback OIDC — OBSOLETO. + * + * El CRM ya no inicia flujo de autorización contra Keycloak: el login entra por + * el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange). Esta + * ruta se conserva solo para no romper enlaces viejos; cualquier acceso se + * redirige al dashboard (el layout valida la sesión y, si no hay, reenvía al + * Workspace). No se intercambia ningún `code` con Keycloak. + */ +export const load: PageServerLoad = async () => { + throw redirect(303, '/dashboard'); }; diff --git a/frontend/src/routes/auth/post-logout/+server.ts b/frontend/src/routes/auth/post-logout/+server.ts index 5c3ba8d..1dc6ec6 100644 --- a/frontend/src/routes/auth/post-logout/+server.ts +++ b/frontend/src/routes/auth/post-logout/+server.ts @@ -3,11 +3,10 @@ import type { RequestHandler } from './$types'; import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth'; /** - * KC redirects here after completing the logout flow. - * This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*). - * We then send the user to workspace login so it can apply myApps() launcher logic. + * Ruta de retorno post-logout. El CRM ya no dispara logout contra Keycloak + * (el cierre completo se hace desde el Workspace); se conserva por compatibilidad + * y redirige al App Launcher del Workspace. */ -export const GET: RequestHandler = async ({ request, url }) => { - const systemBaseUrl = url.origin; - throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true })); +export const GET: RequestHandler = async () => { + throw redirect(303, getWorkspaceLoginUrl()); }; diff --git a/frontend/src/routes/join/+page.server.ts b/frontend/src/routes/join/+page.server.ts index bd58d8b..db0690f 100644 --- a/frontend/src/routes/join/+page.server.ts +++ b/frontend/src/routes/join/+page.server.ts @@ -1,7 +1,7 @@ import { redirect, fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; import { getServerApiUrl, getAuthTokens } from '$lib/server/api'; -import { redirectToKeycloakLogin } from '$lib/server/workspace-auth'; +import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth'; export const load: PageServerLoad = async ({ url, cookies, fetch }) => { const code = url.searchParams.get('code')?.toUpperCase().trim() ?? ''; @@ -13,7 +13,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => { if (!accessToken) { // Sesión KC expiró entre redirecciones — volver a auth - redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`); + redirectToWorkspaceLogin(cookies, url); } const apiUrl = getServerApiUrl(); @@ -76,7 +76,7 @@ export const actions: Actions = { if (!accessToken) { // Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume - redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`); + redirectToWorkspaceLogin(cookies, url); } // Si ya hay sesión, consumir directamente vía redirect a step=consume diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 66d5d5f..ff64071 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -4,12 +4,9 @@ import type { Actions, PageServerLoad } from './$types'; import { clearAuthTokens, getAuthTokens } from '$lib/server/api'; import { setAccessTokenCookies } from '$lib/server/access-token-cookie'; import { - getWorkspaceLoginUrl, readWorkspaceReturnPath, clearWorkspaceReturnPath, - storeReturnPath, - redirectToKeycloakAuthorization, - redirectToKeycloakLogin, + redirectToWorkspaceLogin, getHubBackendUrl, isSecureContext, } from '$lib/server/workspace-auth'; @@ -65,29 +62,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => { clearAuthTokens(cookies); + // Vuelta desde el Workspace tras autenticarse: el CRM NO inicia ningún flujo + // OIDC contra Keycloak. La sesión se obtiene por relay del App Launcher + // (→ /auth/sso). Si el usuario llega aquí ya autenticado en el Hub, se le + // manda al dashboard; si no hay sesión local, el layout lo reenvía al + // Workspace (App Launcher) para re-entrar por relay. if (url.searchParams.get('sso_verified') === '1') { - const existingReturnPath = readWorkspaceReturnPath(cookies, ''); - const intendedPath = - existingReturnPath && existingReturnPath !== '/login' - ? existingReturnPath - : (url.searchParams.get('redirect') || '/dashboard'); - storeReturnPath(cookies, intendedPath); - redirectToKeycloakAuthorization(url.origin, intendedPath); + throw redirect(303, '/dashboard'); } - const redirectParam = url.searchParams.get('redirect'); - if (redirectParam) { - const existingReturnPath = readWorkspaceReturnPath(cookies, ''); - const intendedPath = - existingReturnPath && existingReturnPath !== '/login' - ? existingReturnPath - : redirectParam; - storeReturnPath(cookies, intendedPath); - redirectToKeycloakLogin(url.origin, intendedPath); - } - - storeReturnPath(cookies, '/dashboard'); - throw redirect(303, getWorkspaceLoginUrl(url.origin)); + // Sin sesión → App Launcher del Workspace (relay). Nunca Keycloak directo. + redirectToWorkspaceLogin(cookies, url); }; export const actions: Actions = { diff --git a/frontend/src/routes/logout/+server.ts b/frontend/src/routes/logout/+server.ts index 8e692e7..4ccba83 100644 --- a/frontend/src/routes/logout/+server.ts +++ b/frontend/src/routes/logout/+server.ts @@ -1,37 +1,28 @@ import { redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import type { RequestHandler } from './$types'; -import { clearAccessTokenCookies } from '$lib/server/access-token-cookie'; -import { - buildKeycloakLogoutUrl, - clearWorkspaceReturnPath, - getWorkspaceLoginUrl -} from '$lib/server/workspace-auth'; +import { clearAuthTokens } from '$lib/server/api'; +import { clearWorkspaceReturnPath, getWorkspaceLoginUrl } from '$lib/server/workspace-auth'; -export const POST: RequestHandler = async ({ cookies, url }) => { - const systemBaseUrl = url.origin; - - const idToken = cookies.get('id_token'); - - // Eliminar todas las cookies de autenticación - clearAccessTokenCookies(cookies); - cookies.delete('refresh_token', { path: '/' }); +/** + * Logout del CRM. Limpia la sesión LOCAL (cookies) y devuelve al Workspace. + * NO habla directo a Keycloak: el cierre de sesión completo (Hub/KC) se hace + * desde el Workspace. Patrón SIWEB. + */ +export const POST: RequestHandler = async ({ cookies }) => { + // Eliminar todas las cookies de autenticación (incluye sesión local + token KC) + clearAuthTokens(cookies); cookies.delete('id_token', { path: '/' }); - cookies.delete('active_company_id', { path: '/' }); - cookies.delete('active_system', { path: '/' }); cookies.delete('sso_tenant_id', { path: '/' }); cookies.delete('sso_tenant_pub', { path: '/' }); clearWorkspaceReturnPath(cookies); - // En modo local no hay Keycloak ni Hub — ir directo al login local. + // Modo local: no hay Workspace — ir al login local. if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') { throw redirect(303, '/login'); } - // Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado. - if (!idToken) { - throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true })); - } - - throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken)); + // Volver al Workspace (App Launcher). Para cerrar la sesión del Hub por + // completo, el usuario cierra sesión desde el Workspace. + throw redirect(303, getWorkspaceLoginUrl()); }; From 29170f7c8ca59248fae05743660f45783f78a2a6 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 09:43:32 -0600 Subject: [PATCH 07/40] =?UTF-8?q?fix(auth):=20validar=20licencia=20sin=20r?= =?UTF-8?q?eenviar=20la=20sesi=C3=B3n=20local=20al=20Hub=20(rompe=20el=20b?= =?UTF-8?q?ucle=20401)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El LicenseValidationMiddleware reenviaba el Bearer al Hub /verify-license, pero con el patrón SIWEB el Bearer es un JWT HS256 local que el Hub no entiende → 401 → silent-refresh infinito → toast "Sesión expirada" en cada página. Ahora, para una sesión local válida, la licencia se valida con el token KC guardado en valkey (refrescándolo si está vencido) y se cachea por tenant (TTL 10 min). Si el Hub no es concluyente (su refresh falla), se permite el paso (la sesión se emitió tras un login válido) evitando el bucle; los resultados concluyentes sí se cachean. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/core/middleware.py | 148 +++++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/backend/core/middleware.py b/backend/core/middleware.py index 89d7001..64fc8e1 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -3,6 +3,7 @@ import time import httpx from datetime import datetime, timezone from typing import Callable, Optional +from cachetools import TTLCache from fastapi import Request, Response from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware @@ -12,6 +13,11 @@ from .security import get_tenant_from_token, verify_token, get_active_system logger = logging.getLogger(__name__) +# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al +# Hub en cada request. Valor: "valid" o "invalid:". TTL corto para que +# los cambios de licencia se propaguen en minutos. +_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600) + def _normalize_text(value: str | None) -> str: if not value: @@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): token = auth_header.split(" ")[1] + # Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que + # el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC + # guardado en valkey y se cachea por tenant. + if getattr(settings, "SESSION_STORE_ENABLED", False): + try: + from core.local_session import verify_session_token + local_claims = verify_session_token(token) + except Exception: + local_claims = None + if local_claims is not None: + return await self._handle_local_session_license(request, call_next, local_claims) + tenant_override = request.headers.get("X-Tenant-Override") if not tenant_override: # Fallback para flujos SSO cuando el override no viaja en header. @@ -307,6 +325,136 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware): } ) + async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict): + """ + Valida licencia para una sesión local del CRM (patrón SIWEB). + + El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en + valkey (refrescándolo si está vencido) para consultar verify-license, con + caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se + permite el paso: la sesión local se emitió tras un login válido (el App + Launcher solo ofrece apps licenciadas), evitando bloquear por un problema + transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean. + """ + from core import session_store + + tenant_key = str(local_claims.get("tenant_id") or "") + + cached = _license_cache.get(tenant_key) if tenant_key else None + if cached == "valid": + return await call_next(request) + if isinstance(cached, str) and cached.startswith("invalid:"): + return JSONResponse( + status_code=402, + content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402}, + ) + + tenant_override = ( + tenant_key + or request.cookies.get("sso_tenant_id") + or request.cookies.get("sso_tenant_pub") + or "" + ) + + sid = request.cookies.get("crm_sid") + sess = session_store.get_session(sid) if sid else None + kc_token = (sess or {}).get("access_token") or "" + kc_refresh = (sess or {}).get("refresh_token") or "" + + async def _verify(tok: str): + if not tok: + return None + headers = {"Authorization": f"Bearer {tok}"} + if tenant_override: + headers["X-Tenant-Override"] = str(tenant_override) + try: + async with httpx.AsyncClient(timeout=5.0) as client: + return await client.get( + f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers + ) + except Exception as exc: + logger.warning("[license] verify-license (sesión local) error de red: %s", exc) + return None + + resp = await _verify(kc_token) + + # ¿El KC token guardado está vencido? Refrescar una vez y reintentar. + needs_refresh = resp is None or resp.status_code == 401 + if not needs_refresh and resp.status_code == 200: + try: + _d = resp.json() + except Exception: + _d = {} + if not _d.get("valid", False) and _is_token_issue_message( + _d.get("message"), _d.get("detail"), _d.get("reason") + ): + needs_refresh = True + + if needs_refresh and kc_refresh: + try: + async with httpx.AsyncClient(timeout=8.0) as client: + rr = await client.post( + f"{settings.HUB_URL}api/v1/auth/refresh", + json={"refresh_token": kc_refresh}, + ) + if rr.status_code == 200: + nt = rr.json() + kc_token = nt.get("access_token") or kc_token + if sid: + session_store.update_session_tokens( + sid, kc_token, nt.get("refresh_token") or kc_refresh + ) + resp = await _verify(kc_token) + else: + logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code) + except Exception as exc: + logger.warning("[license] refresh KC para verify-license falló: %s", exc) + + if resp is not None and resp.status_code == 200: + try: + data = resp.json() + except Exception: + data = {} + if data.get("valid", False): + expires_at_str = data.get("expires_at") + if expires_at_str: + try: + expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00")) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at < datetime.now(timezone.utc): + msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción." + if tenant_key: + _license_cache[tenant_key] = f"invalid:{msg}" + return JSONResponse( + status_code=402, + content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402}, + ) + except (ValueError, TypeError): + pass + if tenant_key: + _license_cache[tenant_key] = "valid" + request.state.license_info = data + return await call_next(request) + + message = data.get("message", "Sin licencia asignada para este tenant") + if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")): + if tenant_key: + _license_cache[tenant_key] = f"invalid:{message}" + return JSONResponse( + status_code=402, + content={"error": "LICENSE_ERROR", "message": message, "status_code": 402}, + ) + + # No concluyente (Hub no dio 200, o el problema de token persiste porque su + # refresh falla): la sesión local es válida → permitir sin cachear. Evita el + # bucle de 401 por el bug de refresh del Hub. + logger.warning( + "[license] verify-license no concluyente para sesión local (tenant=%s) — se permite", + tenant_key, + ) + return await call_next(request) + class RequestLoggingMiddleware(BaseHTTPMiddleware): """ From 0b6848bbddc85554409a75b4774136390667f453 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 10:31:59 -0600 Subject: [PATCH 08/40] =?UTF-8?q?fix(core):=20crear=20compa=C3=B1=C3=ADa?= =?UTF-8?q?=20real=20en=20a76.company=20por=20tenant=20(arregla=20FK=20y?= =?UTF-8?q?=20desbloqueo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit my-companies usaba company_id = tenant_id, pero user_tenants.company_id tiene FK a a76.company (entidad real heredada de Anexo76). Con el tenant real (aduanasoft=11) no existía fila de compañía → ForeignKeyViolation → sin compañía → todo bloqueado. Ahora my-companies lista las compañías del tenant desde a76.company y, si no hay ninguna, crea una por defecto (nombre = el del tenant) en el primer acceso, usando su id real; luego asegura el vínculo usuario↔compañía. Base para el módulo de gestión de compañías. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/routes.py | 73 +++++++++++++++------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 7473724..5c54780 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -431,32 +431,61 @@ async def get_my_companies( _ensure_user_tenant_for_company, ) from api.v1.modules.core.tenants.models import Tenant + from sqlalchemy import text + import logging as _logging + + _log = _logging.getLogger(__name__) tenant_id = resolve_effective_tenant_id_from_user(current_user) if not tenant_id: + # Usuario sin tenant (p. ej. hub_admin global): no hay compañía que resolver. return [] + tenant_id = int(tenant_id) - tenant = db.query(Tenant).filter(Tenant.id == int(tenant_id)).first() - name = ( - (tenant.name if tenant else None) - or current_user.get("tenant_slug") - or "Mi empresa" - ) + # La compañía es una entidad REAL del CRM (a76.company, heredada de Anexo76). + # Se listan las compañías del tenant; si no hay ninguna, se crea una por defecto + # en el primer acceso (nombre = el del tenant). + rows = db.execute( + text("SELECT id, name, rfc, logo FROM a76.company WHERE tenant_id = :tid ORDER BY id"), + {"tid": tenant_id}, + ).fetchall() - # Garantizar el vínculo usuario↔tenant↔company (company_id = tenant_id). + if not rows: + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + default_name = ( + (tenant.name if tenant else None) + or current_user.get("tenant_slug") + or "Mi empresa" + ) + created = db.execute( + text( + "INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) " + "RETURNING id, name, rfc, logo" + ), + {"tid": tenant_id, "name": default_name}, + ).fetchone() + # Alinear la secuencia por si hubo inserts con id explícito (seed dev). + db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) + db.commit() + rows = [created] + _log.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0]) + + # Asegurar el vínculo usuario↔compañía por cada compañía del tenant. user_id = current_user.get("sub") or current_user.get("id") - if user_id: - try: - _ensure_user_tenant_for_company(db, str(user_id), int(tenant_id), int(tenant_id)) - except Exception as exc: - logger = __import__("logging").getLogger(__name__) - logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc) - - return [{ - "id": int(tenant_id), - "name": name, - "tenant_id": int(tenant_id), - "rfc": None, - "logo": None, - "is_active": True, - }] + companies = [] + for r in rows: + cid = int(r[0]) + if user_id: + try: + _ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid) + except Exception as exc: + _log.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc) + companies.append({ + "id": cid, + "name": r[1] or "Mi empresa", + "tenant_id": tenant_id, + "rfc": r[2], + "logo": r[3], + "is_active": True, + }) + return companies From 63ad2e2ecd8419e95838754f8ad89f6f599debcf Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 11:44:36 -0600 Subject: [PATCH 09/40] =?UTF-8?q?feat(core,crm):=20gesti=C3=B3n=20de=20com?= =?UTF-8?q?pa=C3=B1=C3=ADas=20en=20el=20CRM=20+=20soporte=20hub=5Fadmin=20?= =?UTF-8?q?sin=20tenant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del Workspace. Cambios: - my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios con tenant, autocrea una por defecto en el primer acceso. - POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario. GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo). - security.validate_access_to_resource: si el token no trae tenant (hub_admin), resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar por compañía seleccionada. - set-active: fija sso_tenant_id/pub con el tenant de la compañía (override). - Pantalla "Compañías" (nav) para crear/listar/seleccionar. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/routes.py | 187 +++++++++++++----- backend/core/security.py | 14 ++ .../src/lib/components/sidebar/modules.ts | 6 + frontend/src/lib/stores/company.svelte.ts | 4 +- .../company/set-active/+server.ts | 26 ++- .../routes/dashboard/companias/+page.svelte | 157 +++++++++++++++ 6 files changed, 335 insertions(+), 59 deletions(-) create mode 100644 frontend/src/routes/dashboard/companias/+page.svelte diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 5c54780..e313a7e 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -24,6 +24,12 @@ from .dto import ( ) from .service import AuthService +import logging +from typing import Optional +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/auth", tags=["Authentication"]) security = HTTPBearer() @@ -432,60 +438,145 @@ async def get_my_companies( ) from api.v1.modules.core.tenants.models import Tenant from sqlalchemy import text - import logging as _logging - - _log = _logging.getLogger(__name__) + user_id = current_user.get("sub") or current_user.get("id") tenant_id = resolve_effective_tenant_id_from_user(current_user) - if not tenant_id: - # Usuario sin tenant (p. ej. hub_admin global): no hay compañía que resolver. - return [] - tenant_id = int(tenant_id) - # La compañía es una entidad REAL del CRM (a76.company, heredada de Anexo76). - # Se listan las compañías del tenant; si no hay ninguna, se crea una por defecto - # en el primer acceso (nombre = el del tenant). + # 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por + # defecto en el primer acceso y asegura la membresía. + if tenant_id: + tenant_id = int(tenant_id) + exists = db.execute( + text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"), + {"tid": tenant_id}, + ).fetchone() + if not exists: + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + default_name = ( + (tenant.name if tenant else None) + or current_user.get("tenant_slug") + or "Mi empresa" + ) + created = db.execute( + text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"), + {"tid": tenant_id, "name": default_name}, + ).fetchone() + db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) + db.commit() + logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0]) + if user_id: + try: + _ensure_user_tenant_for_company(db, str(user_id), tenant_id, int(created[0])) + except Exception as exc: + logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc) + + # 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona + # también para hub_admin sin tenant en el token: verá las compañías que creó + # o a las que fue asignado. La membresía la determina el CRM, no el Hub. + if not user_id: + return [] rows = db.execute( - text("SELECT id, name, rfc, logo FROM a76.company WHERE tenant_id = :tid ORDER BY id"), - {"tid": tenant_id}, + text( + """ + SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id + FROM a76.company c + WHERE c.id IN ( + SELECT company_id FROM core.user_tenants + WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL + UNION + SELECT company_id FROM core.user_company_roles + WHERE user_id = :uid AND is_active + ) + ORDER BY c.id + """ + ), + {"uid": str(user_id)}, ).fetchall() - if not rows: - tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() - default_name = ( - (tenant.name if tenant else None) - or current_user.get("tenant_slug") - or "Mi empresa" - ) - created = db.execute( - text( - "INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) " - "RETURNING id, name, rfc, logo" - ), - {"tid": tenant_id, "name": default_name}, - ).fetchone() - # Alinear la secuencia por si hubo inserts con id explícito (seed dev). - db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) - db.commit() - rows = [created] - _log.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0]) - - # Asegurar el vínculo usuario↔compañía por cada compañía del tenant. - user_id = current_user.get("sub") or current_user.get("id") - companies = [] - for r in rows: - cid = int(r[0]) - if user_id: - try: - _ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid) - except Exception as exc: - _log.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc) - companies.append({ - "id": cid, - "name": r[1] or "Mi empresa", - "tenant_id": tenant_id, + return [ + { + "id": int(r[0]), + "name": r[1] or "Empresa", + "tenant_id": int(r[4]), "rfc": r[2], "logo": r[3], "is_active": True, - }) - return companies + } + for r in rows + ] + + +class _CreateCompanyDTO(BaseModel): + name: str + tenant_id: int + rfc: Optional[str] = None + + +@router.get("/assignable-tenants") +async def assignable_tenants( + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Tenants disponibles para asignar una compañía nueva. El tenant lo crea el + Workspace; aquí solo se elige. hub_admin ve todos; un usuario con tenant ve el suyo. + """ + from api.v1.modules.core.tenants.models import Tenant + from core.security import resolve_effective_tenant_id_from_user, is_hub_admin + + q = db.query(Tenant).filter(Tenant.is_active == True) # noqa: E712 + tid = resolve_effective_tenant_id_from_user(current_user) + if tid and not is_hub_admin(current_user): + q = q.filter(Tenant.id == int(tid)) + return [{"id": t.id, "name": t.name, "slug": t.slug} for t in q.order_by(Tenant.id).all()] + + +@router.post("/companies", status_code=201) +async def create_company( + data: _CreateCompanyDTO, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al + usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con + tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me). + """ + from sqlalchemy import text as _text + from api.v1.modules.core.tenants.models import Tenant + from core.security import ( + resolve_effective_tenant_id_from_user, + is_hub_admin, + _ensure_user_tenant_for_company, + ) + + name = (data.name or "").strip() + if len(name) < 2: + raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.") + + tid = int(data.tenant_id) + tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712 + if not tenant: + raise HTTPException(status_code=404, detail="Tenant no encontrado.") + + if not is_hub_admin(current_user): + own = resolve_effective_tenant_id_from_user(current_user) + if own is None or int(own) != tid: + raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.") + + created = db.execute( + _text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"), + {"t": tid, "n": name, "r": (data.rfc or None)}, + ).fetchone() + db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) + db.commit() + cid = int(created[0]) + + user_id = current_user.get("sub") or current_user.get("id") + if user_id: + try: + _ensure_user_tenant_for_company(db, str(user_id), tid, cid) + except Exception as exc: + logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc) + + return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True} diff --git a/backend/core/security.py b/backend/core/security.py index 9127316..ced790d 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -666,6 +666,20 @@ def validate_access_to_resource( tenant_id = resolve_effective_tenant_id_from_user(current_user) + # Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace), + # resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar + # por compañía seleccionada cuando el token no está ligado a un tenant. + if tenant_id is None and company_id: + try: + from sqlalchemy import text as _text + row = db.execute( + _text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id} + ).first() + if row and row[0] is not None: + tenant_id = int(row[0]) + except Exception as exc: + logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc) + # Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me) # o rol local "super_admin" en la compañía (fuente de verdad: BD de a76). # Se reemplazó el antiguo "admin" in realm_access.roles para que la diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 6792c2b..50ca5a1 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -7,6 +7,7 @@ import { Ship, Receipt, Building2, + Building, } from '@lucide/svelte'; export type SystemContext = 'fixed_asset' | 'inventory'; @@ -70,6 +71,11 @@ export function getNavMain(): NavMainItem[] { { title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, ], }, + { + title: 'Compañías', + url: '/dashboard/companias', + icon: Building, + }, { title: 'Workspace', url: '/dashboard/workspace/organizaciones', diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 4b8794a..4618e4b 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -158,7 +158,9 @@ class CompanyStore { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ companyId: company.id }), + // tenant_id de la compañía → override para que el backend escale por + // ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token). + body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }), credentials: 'include' }); } catch (error) { diff --git a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts index 3820adf..18326e1 100644 --- a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts +++ b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts @@ -6,22 +6,28 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ cookies, request }) => { try { - const { companyId } = await request.json(); + const body = await request.json(); + const companyId = body?.companyId; + const tenantId = body?.tenantId; if (!companyId || typeof companyId !== 'number') { return json({ error: 'Invalid company ID' }, { status: 400 }); } - // Establecer la cookie desde el servidor - cookies.set('active_company_id', companyId.toString(), { - path: '/', - maxAge: 60 * 60 * 24 * 30, // 30 días - sameSite: 'lax', - httpOnly: false, // Permitir acceso desde JavaScript - secure: process.env.NODE_ENV === 'production' - }); + const isProd = process.env.NODE_ENV === 'production'; + const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd }; - return json({ success: true, companyId }); + // Compañía activa (legible desde JS) + cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false }); + + // Fijar el tenant de la compañía como override → el backend escala por ese + // tenant aunque el token no lo traiga (caso hub_admin operando por compañía). + if (typeof tenantId === 'number' && Number.isFinite(tenantId)) { + cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true }); + cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false }); + } + + return json({ success: true, companyId, tenantId: tenantId ?? null }); } catch (error) { console.error('Error setting active company:', error); return json({ error: 'Internal server error' }, { status: 500 }); diff --git a/frontend/src/routes/dashboard/companias/+page.svelte b/frontend/src/routes/dashboard/companias/+page.svelte new file mode 100644 index 0000000..8ea54f7 --- /dev/null +++ b/frontend/src/routes/dashboard/companias/+page.svelte @@ -0,0 +1,157 @@ + + +
+
+

+ Compañías +

+

+ Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del + Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa. +

+
+ + + + Nueva compañía + El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa. + + +
+ + + +
+
+ +
+
+
+ + + + Compañías ({companies.length}) + Empresas a las que tienes acceso. + + + {#if loading} +

Cargando…

+ {:else if companies.length === 0} +

Aún no tienes compañías. Crea una arriba.

+ {:else} +
+ + + + + + + + + + + {#each companies as c (c.id)} + + + + + + + {/each} + +
NombreRFCTenantActiva
{c.name}{c.rfc ?? '—'}{c.tenant_id} + {#if companyStore.activeCompany?.id === c.id} + activa + {:else} + + {/if} +
+
+ {/if} +
+
+
From ce8b042e84f0e3330616ee10d36751f081062fa6 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 12:27:27 -0600 Subject: [PATCH 10/40] =?UTF-8?q?fix(auth):=20hornear=20is=5Fhub=5Fadmin?= =?UTF-8?q?=20(autoritativo=20del=20Hub)=20en=20la=20sesi=C3=B3n=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_company (y otros checks) usan is_hub_admin, pero la sesión local se emitía desde el token KC crudo, que no trae ese claim → el hub_admin sin tenant recibía 403 al crear compañía. Ahora la sesión se emite con los claims de /auth/me del Hub (is_hub_admin, roles), con fallback al decode del token KC si el Hub no responde. Se mantiene intacto el control de autorización (solo hub_admin crea fuera de su tenant). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/routes.py | 2 ++ backend/api/v1/modules/core/auth/service.py | 31 +++++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index e313a7e..3dfe7eb 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -559,6 +559,8 @@ async def create_company( if not tenant: raise HTTPException(status_code=404, detail="Tenant no encontrado.") + # Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant; + # un usuario ligado a un tenant, solo en el suyo. if not is_hub_admin(current_user): own = resolve_effective_tenant_id_from_user(current_user) if own is None or int(own) != tid: diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py index 040e220..8c707be 100644 --- a/backend/api/v1/modules/core/auth/service.py +++ b/backend/api/v1/modules/core/auth/service.py @@ -235,7 +235,7 @@ class AuthService: return claims def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]: - """Construye los claims de la sesión local a partir de la respuesta KC del Hub.""" + """Construye los claims de la sesión local a partir del token KC (decode).""" kc_claims = self._decode_kc_user_from_token(data.get("access_token", "")) claims: Dict[str, Any] = dict(kc_claims) # tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token @@ -245,6 +245,33 @@ class AuthService: claims["tenant_slug"] = data.get("tenant_slug") return claims + async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae + is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no + responde, se cae al decode del token KC. Así la sesión local sabe si el + usuario es hub_admin sin volver a consultar al Hub en cada request. + """ + from core.security import verify_token + + claims: Dict[str, Any] = {} + try: + info = await verify_token(data.get("access_token", "")) + if isinstance(info, dict): + claims = dict(info) + except Exception as exc: + logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc) + + if not claims: + return self._session_claims_from_kc(data) + + # tenant_id/tenant_slug explícitos del Hub tienen precedencia. + if data.get("tenant_id") is not None: + claims["tenant_id"] = data.get("tenant_id") + if data.get("tenant_slug") is not None: + claims["tenant_slug"] = data.get("tenant_slug") + return claims + async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO: """ Refresca la sesión. @@ -321,7 +348,7 @@ class AuthService: from core import local_session, session_store start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp()) - claims = self._session_claims_from_kc(data) + claims = await self._session_claims(data) new_access = data.get("access_token", "") new_refresh = data.get("refresh_token", "") # Reutiliza la sesión de valkey si ya existía; si no, la crea. From 706da34f4a0719c58442e897e81edc2766c8eba6 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 12:44:51 -0600 Subject: [PATCH 11/40] =?UTF-8?q?feat(core):=20auto-ligar=20usuario=20a=20?= =?UTF-8?q?todas=20las=20compa=C3=B1=C3=ADas=20de=20su=20tenant=20(sin=20r?= =?UTF-8?q?ol)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes solo se ligaba al usuario que CREABA la compañía; un segundo usuario del mismo tenant no quedaba ligado → veía "sin compañía". Ahora, al entrar, el usuario con tenant queda como MIEMBRO de todas las compañías de su tenant (solo membresía; el rol lo asigna un admin, salvo el primer usuario que recibe super_admin en /permissions/me). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/routes.py | 29 +++++++++++++++------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 3dfe7eb..e5ee398 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -443,14 +443,21 @@ async def get_my_companies( tenant_id = resolve_effective_tenant_id_from_user(current_user) # 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por - # defecto en el primer acceso y asegura la membresía. + # defecto en el primer acceso y AUTO-LIGA al usuario a TODAS las compañías de + # su tenant. Así cualquier usuario del mismo tenant (misma organización del + # Workspace) entra y ve la(s) compañía(s) sin gestión manual. El ROL no se + # asigna aquí: es solo membresía; los permisos se otorgan aparte (un admin + # asigna el rol; el primer usuario recibe super_admin vía /permissions/me). if tenant_id: tenant_id = int(tenant_id) - exists = db.execute( - text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"), - {"tid": tenant_id}, - ).fetchone() - if not exists: + company_ids = [ + int(r[0]) + for r in db.execute( + text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id"), + {"tid": tenant_id}, + ).fetchall() + ] + if not company_ids: tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() default_name = ( (tenant.name if tenant else None) @@ -463,12 +470,16 @@ async def get_my_companies( ).fetchone() db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) db.commit() + company_ids = [int(created[0])] logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0]) - if user_id: + + # Auto-ligado por tenant (solo membresía, sin rol). + if user_id: + for cid in company_ids: try: - _ensure_user_tenant_for_company(db, str(user_id), tenant_id, int(created[0])) + _ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid) except Exception as exc: - logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc) + logger.warning("auto-ligado de compañía %s falló (no bloquea): %s", cid, exc) # 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona # también para hub_admin sin tenant en el token: verá las compañías que creó From 387b3c0e789bd5967d6f6aa76e3375444aa91dc9 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 13:20:50 -0600 Subject: [PATCH 12/40] =?UTF-8?q?feat(core,crm):=20auto-sync=20de=20tenant?= =?UTF-8?q?s=20Workspace=E2=86=92CRM=20+=20mostrar=20tenant=20de=20la=20co?= =?UTF-8?q?mpa=C3=B1=C3=ADa=20activa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assignable-tenants: para hub_admin, sincroniza automáticamente los tenants del Workspace (Hub GET /hub/tenants) a core.tenants con su mismo ID, y los devuelve. Así los tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías (best-effort con el token KC de la sesión). CRM→Workspace ya lo hace Organizaciones (POST /hub/tenants). - my-companies devuelve tenant_name/tenant_slug (join core.tenants). - Switcher muestra el tenant de la compañía activa (antes "Sin tenant asignado"). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/auth/routes.py | 84 +++++++++++++++++-- .../components/sidebar/team-switcher.svelte | 15 +++- frontend/src/lib/stores/company.svelte.ts | 2 + 3 files changed, 91 insertions(+), 10 deletions(-) diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index e5ee398..50d6869 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -489,8 +489,9 @@ async def get_my_companies( rows = db.execute( text( """ - SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id + SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug FROM a76.company c + LEFT JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id IN ( SELECT company_id FROM core.user_tenants WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL @@ -509,6 +510,8 @@ async def get_my_companies( "id": int(r[0]), "name": r[1] or "Empresa", "tenant_id": int(r[4]), + "tenant_name": r[5], + "tenant_slug": r[6], "rfc": r[2], "logo": r[3], "is_active": True, @@ -523,23 +526,90 @@ class _CreateCompanyDTO(BaseModel): rfc: Optional[str] = None +async def _sync_tenants_from_hub(request: Request, db: Session) -> None: + """ + Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y + los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los + tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías. + Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no + responde, no bloquea (se devuelven los tenants ya sincronizados). + """ + import httpx + from sqlalchemy import text as _text + from core.config import settings + from core import session_store + from api.v1.modules.core.tenants.models import Tenant, TenantType + + sid = request.cookies.get("crm_sid") if request else None + kc_token = None + if sid: + sess = session_store.get_session(sid) + kc_token = (sess or {}).get("access_token") + if not kc_token: + return + + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.get( + f"{settings.HUB_URL}api/v1/hub/tenants", + headers={"Authorization": f"Bearer {kc_token}"}, + ) + if r.status_code != 200: + logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code) + return + payload = r.json() + items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or []) + for t in items: + tid = t.get("id") + if tid is None: + continue + name = t.get("name") or t.get("display_name") or t.get("slug") + slug = t.get("slug") or f"tenant-{tid}" + existing = db.query(Tenant).filter(Tenant.id == int(tid)).first() + if existing: + if name and existing.name != name: + existing.name = name + else: + db.add(Tenant( + id=int(tid), name=name or slug, slug=slug, + keycloak_realm=slug, type=TenantType.SHARED, is_active=True, + )) + db.commit() + db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))")) + db.commit() + except Exception as exc: + logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc) + try: + db.rollback() + except Exception: + pass + + @router.get("/assignable-tenants") async def assignable_tenants( + request: Request, current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db), ): """ - Tenants disponibles para asignar una compañía nueva. El tenant lo crea el - Workspace; aquí solo se elige. hub_admin ve todos; un usuario con tenant ve el suyo. + Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace; + aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario + con tenant ve el suyo. """ from api.v1.modules.core.tenants.models import Tenant from core.security import resolve_effective_tenant_id_from_user, is_hub_admin - q = db.query(Tenant).filter(Tenant.is_active == True) # noqa: E712 + if is_hub_admin(current_user): + # Sincroniza automáticamente los tenants del Workspace antes de listar. + await _sync_tenants_from_hub(request, db) + rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712 + return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows] + tid = resolve_effective_tenant_id_from_user(current_user) - if tid and not is_hub_admin(current_user): - q = q.filter(Tenant.id == int(tid)) - return [{"id": t.id, "name": t.name, "slug": t.slug} for t in q.order_by(Tenant.id).all()] + if tid: + t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712 + return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else [] + return [] @router.post("/companies", status_code=201) diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index 547c9c1..f4cd37f 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -145,9 +145,18 @@ > Tenant {#if userTenants.length === 0} - - Sin tenant asignado - + {#if companyStore.activeCompany?.tenant_name} + +
+ +
+ {companyStore.activeCompany.tenant_name} +
+ {:else} + + Sin tenant asignado + + {/if} {:else} {#each userTenants as tenant (tenant.id)} Date: Fri, 17 Jul 2026 13:31:11 -0600 Subject: [PATCH 13/40] =?UTF-8?q?feat(core):=20gesti=C3=B3n=20de=20Usuario?= =?UTF-8?q?s=20y=20Roles/permisos=20(pantallas)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Roles y permisos: crear/eliminar roles por compañía y editar sus permisos (checkboxes agrupados por módulo) vía rolesAPI + rolePermissionsAPI + permissionsAPI. - Usuarios: lista usuarios de la compañía activa y permite asignar/quitar roles vía usersAPI + userRolesAPI. Reusa los clientes API ya existentes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/routes/dashboard/roles/+page.svelte | 234 ++++++++++++++++-- .../src/routes/dashboard/users/+page.svelte | 177 +++++++++++-- 2 files changed, 382 insertions(+), 29 deletions(-) diff --git a/frontend/src/routes/dashboard/roles/+page.svelte b/frontend/src/routes/dashboard/roles/+page.svelte index 938eb89..0f60673 100644 --- a/frontend/src/routes/dashboard/roles/+page.svelte +++ b/frontend/src/routes/dashboard/roles/+page.svelte @@ -1,26 +1,230 @@
-

- - Roles y permisos +

+ Roles y permisos

-

- Gestión de roles y control de acceso. +

+ Define roles (carriles) por compañía y qué puede hacer cada uno. Los usuarios se asignan en Usuarios.

- - - Roles del sistema - Implementa aquí la gestión de roles y permisos de tu proyecto. - - -

Sección en construcción.

-
-
+ {#if !companyId} + Selecciona una compañía activa para gestionar sus roles. + {:else} + + + Nuevo rol + + +
+ + + +
+
+
+
+ + + + Roles ({roles.length}) + Haz clic en un rol para ver y editar sus permisos. + + + {#if loading} +

Cargando…

+ {:else if roles.length === 0} +

No hay roles. Crea uno arriba.

+ {:else} + {#each roles as r (r.id)} +
+
+ + +
+ {#if expandedRoleId === r.id} +
+ {#if !rolePerms[r.id]} +

Cargando permisos…

+ {:else} +
+ {#each Object.entries(byModule) as [mod, perms] (mod)} +
+

{mod}

+
+ {#each perms as p (p.id)} + + {/each} +
+
+ {/each} +
+ {/if} +
+ {/if} +
+ {/each} + {/if} +
+
+ {/if}
diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 4ecfca5..ec059e8 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -1,23 +1,172 @@
-

- - Usuarios +

+ Usuarios

-

Gestión de usuarios y accesos.

+

+ Usuarios de la compañía activa y sus roles. Para dar de alta usuarios nuevos usa Workspace → Usuarios (invitaciones). +

- - - Usuarios del sistema - Implementa aquí la gestión de usuarios de tu proyecto. - - -

Sección en construcción.

-
-
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} + + + Usuarios ({users.length}) + Asigna o quita roles (los roles se definen en Roles y permisos). + + + {#if loading} +

Cargando…

+ {:else if users.length === 0} +

No hay usuarios en esta compañía todavía.

+ {:else} +
+ + + + + + + + + + + {#each users as u (u.id)} + + + + + + + {/each} + +
UsuarioEmailRolesAsignar rol
{fullName(u)}{u.email ?? '—'} +
+ {#each rolesByUser[String(u.id)] ?? [] as a (a.id)} + + {a.company_role?.name ?? a.company_role?.code ?? `rol ${a.company_role_id}`} + + + {:else} + sin rol + {/each} +
+
+ +
+
+ {/if} +
+
+ {/if}
From 868724d1f279c483b6a652ab2af3232792d092b8 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 16:03:37 -0600 Subject: [PATCH 14/40] =?UTF-8?q?fix(core):=20usar=20token=20KC=20(valkey)?= =?UTF-8?q?=20para=20listar=20usuarios=20del=20tenant,=20no=20la=20sesi?= =?UTF-8?q?=C3=B3n=20local?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_users/stats llamaban al Hub (users-with-info) con el Bearer de la app, que con el patrón SIWEB es la sesión local (HS256) → el Hub la rechaza (401) → 0 usuarios. Nuevo core.hub_token.get_hub_access_token: toma el token KC de la sesión (valkey vía crm_sid) y lo refresca si está por expirar. list_users y stats lo usan para el Hub. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/users/routes.py | 12 ++++ backend/core/hub_token.py | 67 +++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 backend/core/hub_token.py diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 4dd1a10..44a9d2b 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -53,12 +53,17 @@ async def get_user_statistics( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + from core.hub_token import get_hub_access_token + auth_header = request.headers.get("Authorization") or "" token = ( auth_header[7:].strip() if auth_header.lower().startswith("bearer ") else auth_header.strip() ) + kc_token = await get_hub_access_token(request) + if kc_token: + token = kc_token hub_tid = resolve_hub_tenant_id_for_api( tenant_id, request.headers.get("X-Tenant-Override") ) @@ -84,12 +89,19 @@ async def list_users( """ tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"]) service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user)) + # El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta. + # Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado. + from core.hub_token import get_hub_access_token + auth_header = request.headers.get("Authorization") or "" token = ( auth_header[7:].strip() if auth_header.lower().startswith("bearer ") else auth_header.strip() ) + kc_token = await get_hub_access_token(request) + if kc_token: + token = kc_token hub_tid = resolve_hub_tenant_id_for_api( tenant_id, request.headers.get("X-Tenant-Override") ) diff --git a/backend/core/hub_token.py b/backend/core/hub_token.py new file mode 100644 index 0000000..2c2c6a3 --- /dev/null +++ b/backend/core/hub_token.py @@ -0,0 +1,67 @@ +""" +Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub. + +Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256) +que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en +la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar. +""" + +import logging +import time +from typing import Optional + +import httpx +from jose import jwt + +from core.config import settings +from core import session_store + +logger = logging.getLogger(__name__) + + +def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool: + """True si el token KC no está expirado (con margen).""" + try: + claims = jwt.get_unverified_claims(token) + exp = claims.get("exp") + return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds + except Exception: + return False + + +async def get_hub_access_token(request) -> Optional[str]: + """ + Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid), + refrescándolo contra el Hub si está por expirar. None si no hay sesión. + Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido). + """ + sid = request.cookies.get("crm_sid") if request is not None else None + if not sid: + return None + sess = session_store.get_session(sid) + if not sess: + return None + + access = sess.get("access_token") + refresh = sess.get("refresh_token") + + if access and _kc_exp_ok(access): + return access + + if refresh: + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.post( + f"{settings.HUB_URL}api/v1/auth/refresh", + json={"refresh_token": refresh}, + ) + if r.status_code == 200: + data = r.json() + new_access = data.get("access_token") or access + session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh) + return new_access + logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code) + except Exception as exc: + logger.warning("get_hub_access_token: refresh falló: %s", exc) + + return access From de9e35c5015b0f16ba433f053b146eac2d8e64c0 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 16:12:11 -0600 Subject: [PATCH 15/40] =?UTF-8?q?feat(core):=20bot=C3=B3n=20"dar=20de=20al?= =?UTF-8?q?ta=20usuario"=20(invitaci=C3=B3n)=20en=20Usuarios=20+=20fix=20h?= =?UTF-8?q?ub=5Fadmin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend Usuarios: formulario para dar de alta (email + rol) → invitación; muestra el enlace copiable por si el correo no llega. - Backend invites: resuelve tenant_slug desde la compañía cuando el token no lo trae (hub_admin) y usa el token KC de la sesión (valkey) para crear el invite en el Hub. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/core/invites/routes.py | 22 ++++- .../src/routes/dashboard/users/+page.svelte | 83 ++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/backend/api/v1/modules/core/invites/routes.py b/backend/api/v1/modules/core/invites/routes.py index a134440..3ed0883 100644 --- a/backend/api/v1/modules/core/invites/routes.py +++ b/backend/api/v1/modules/core/invites/routes.py @@ -37,13 +37,33 @@ async def create_invite( required_permissions=["user.create"], ) + # tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el + # token), se resuelve desde la compañía destino (a76.company → core.tenants). tenant_slug: str = current_user.get("tenant_slug") or "" + if not tenant_slug: + from sqlalchemy import text as _text + row = db.execute( + _text( + "SELECT t.slug FROM a76.company c " + "JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c" + ), + {"c": data.company_id}, + ).first() + if row and row[0]: + tenant_slug = row[0] + created_by: str = current_user.get("sub") or "" + # El invite se crea en el Hub: se necesita el token KC (la sesión local no la + # acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta. + from core.hub_token import get_hub_access_token + + kc_token = await get_hub_access_token(request) + service = InviteService(db) return await service.create_invite( data=data, created_by=created_by, tenant_slug=tenant_slug, - user_access_token=credentials.credentials, + user_access_token=kc_token or credentials.credentials, ) diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index ec059e8..67f14a9 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -1,6 +1,7 @@
@@ -112,6 +158,41 @@ {#if !companyId} Selecciona una compañía activa. {:else} + + + Dar de alta usuario + Se envía una invitación por email; el usuario crea su contraseña y queda en la compañía con el rol elegido. + + +
+ + +
+
+ {#if roles.length === 0} + Primero crea roles en "Roles y permisos". + {:else}{/if} + +
+ {#if inviteUrl} +
+ Si el correo no llega, comparte: + + +
+ {/if} +
+
+ Usuarios ({users.length}) From 0d8c4ddf622de1922857d9910ff6277e361d2055 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 16:51:18 -0600 Subject: [PATCH 16/40] =?UTF-8?q?fix(crm):=20cat=C3=A1logo=20de=20permisos?= =?UTF-8?q?=20no=20cargaba=20por=20redirect=20307=20(slash=20final)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit permissionsAPI.list pegaba a /v1/core/permissions/ (con slash); la ruta backend es @router.get("") sin slash, así que FastAPI devolvía 307 y el cliente no lo seguía → "No se pudieron cargar roles/permisos". Se quitan los slash finales de list/getById/create/update/delete/getModules/getActions para que matcheen exacto. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/api/dashboard/admin/permissions.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/api/dashboard/admin/permissions.ts b/frontend/src/lib/api/dashboard/admin/permissions.ts index e6d2ef0..9da2d23 100644 --- a/frontend/src/lib/api/dashboard/admin/permissions.ts +++ b/frontend/src/lib/api/dashboard/admin/permissions.ts @@ -57,7 +57,9 @@ export const permissionsAPI = { if (params?.action) queryParams.set('action', params.action); if (params?.search) queryParams.set('search', params.search); const query = queryParams.toString(); - const response = await api.get(`/v1/core/permissions/${query ? '?' + query : ''}`); + // Sin slash final: la ruta backend es @router.get("") = /v1/core/permissions. + // Con slash FastAPI responde 307 y el cliente server-side no lo sigue. + const response = await api.get(`/v1/core/permissions${query ? '?' + query : ''}`); return response.data; }, @@ -65,7 +67,7 @@ export const permissionsAPI = { * Obtener un permiso por ID */ async getById(id: number): Promise { - const response = await api.get(`/v1/core/permissions/${id}/`); + const response = await api.get(`/v1/core/permissions/${id}`); return response.data; }, @@ -73,7 +75,7 @@ export const permissionsAPI = { * Crear un nuevo permiso */ async create(data: CreatePermissionData): Promise { - const response = await api.post('/v1/core/permissions/', data); + const response = await api.post('/v1/core/permissions', data); return response.data; }, @@ -81,7 +83,7 @@ export const permissionsAPI = { * Actualizar un permiso */ async update(id: number, data: UpdatePermissionData): Promise { - const response = await api.put(`/v1/core/permissions/${id}/`, data); + const response = await api.put(`/v1/core/permissions/${id}`, data); return response.data; }, @@ -89,14 +91,14 @@ export const permissionsAPI = { * Eliminar un permiso */ async delete(id: number): Promise { - await api.delete(`/v1/core/permissions/${id}/`); + await api.delete(`/v1/core/permissions/${id}`); }, /** * Obtener módulos únicos */ async getModules(): Promise { - const response = await api.get('/v1/core/permissions/modules/'); + const response = await api.get('/v1/core/permissions/modules'); return response.data; }, @@ -104,7 +106,7 @@ export const permissionsAPI = { * Obtener acciones únicas */ async getActions(): Promise { - const response = await api.get('/v1/core/permissions/actions/'); + const response = await api.get('/v1/core/permissions/actions'); return response.data; } }; From a14acf59e55526b9ba2c114d75f14474d828d34f Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 16:56:59 -0600 Subject: [PATCH 17/40] =?UTF-8?q?chore(crm):=20ocultar=20pesta=C3=B1a=20Wo?= =?UTF-8?q?rkspace=20del=20sidebar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las altas de organizaciones/usuarios se gestionan desde Compañías, Usuarios y Roles y permisos; se retira la entrada Workspace (y su icono Building2). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/sidebar/modules.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 50ca5a1..0f2cce0 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -6,7 +6,6 @@ import { Briefcase, Ship, Receipt, - Building2, Building, } from '@lucide/svelte'; @@ -76,15 +75,6 @@ export function getNavMain(): NavMainItem[] { url: '/dashboard/companias', icon: Building, }, - { - title: 'Workspace', - url: '/dashboard/workspace/organizaciones', - icon: Building2, - items: [ - { title: 'Organizaciones', url: '/dashboard/workspace/organizaciones' }, - { title: 'Usuarios (invitaciones)', url: '/dashboard/workspace/usuarios' }, - ], - }, { title: 'Usuarios', url: '/dashboard/users', From 0cffd351dfee4ac0e927db8fde2c7f60549b58ba Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 17 Jul 2026 17:03:36 -0600 Subject: [PATCH 18/40] =?UTF-8?q?fix(crm):=20alta=20de=20usuarios=20carga?= =?UTF-8?q?=20compa=C3=B1=C3=ADas=20del=20tenant=20en=20vez=20de=20slug=20?= =?UTF-8?q?a=20mano?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La organización destino salía como texto libre (o fallaba al listar todos los tenants del Hub con un token KC caduco). Ahora se carga desde /v1/auth/my-companies (mismo origen que el switcher) y se elige la compañía; el tenant_slug se resuelve de la compañía seleccionada. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/usuarios/+page.server.ts | 35 +++++++++---------- .../dashboard/workspace/usuarios/+page.svelte | 31 ++++++---------- 2 files changed, 26 insertions(+), 40 deletions(-) diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts index 76f63be..4488b97 100644 --- a/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.server.ts @@ -1,27 +1,24 @@ import { fail } from '@sveltejs/kit'; import type { PageServerLoad, Actions } from './$types'; -import { getKcAccessToken } from '$lib/server/api'; -import { listWorkspaceTenants, createWorkspaceInvite } from '$lib/server/workspace-provision'; +import { getUserCompanies } from '$lib/server/api'; +import { createWorkspaceInvite } from '$lib/server/workspace-provision'; import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared'; export const load: PageServerLoad = async ({ cookies, fetch }) => { - const accessToken = getKcAccessToken(cookies); - if (!accessToken) { - return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; - } + // La organización destino sale de las compañías del usuario (mismo origen que + // el switcher, vía /v1/auth/my-companies). Así no se escribe el slug a mano ni + // se depende de listar todos los tenants del Hub con un token KC que caduca. + const companiesRaw = await getUserCompanies(cookies, fetch); + const companies = (companiesRaw ?? []) + .filter((c) => c && c.tenant_slug) + .map((c) => ({ + id: c.id as number, + name: c.name as string, + tenant_slug: c.tenant_slug as string, + tenant_name: (c.tenant_name as string) || (c.tenant_slug as string) + })); - // Los hub_admin pueden listar todos los tenants (dropdown). Un admin de tenant - // no puede listarlos (403) pero sí puede invitar a SU tenant escribiendo el slug. - const res = await listWorkspaceTenants(accessToken, fetch); - if (!res.ok) { - return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES }; - } - - return { - tenants: res.data.map((t) => ({ slug: t.slug, name: t.display_name || t.name })), - canListTenants: true, - roles: WORKSPACE_INVITE_ROLES - }; + return { companies, roles: WORKSPACE_INVITE_ROLES }; }; export const actions: Actions = { @@ -42,7 +39,7 @@ export const actions: Actions = { return fail(422, { error: 'Ingresa un email válido para invitar.', values }); } if (!tenant_slug) { - return fail(422, { error: 'Selecciona (o escribe) el slug de la organización destino.', values }); + return fail(422, { error: 'Selecciona la compañía destino.', values }); } if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) { return fail(422, { error: 'Rol inválido.', values }); diff --git a/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte index 4b156fd..0401ff3 100644 --- a/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte +++ b/frontend/src/routes/dashboard/workspace/usuarios/+page.svelte @@ -75,13 +75,7 @@ Nueva invitación - - {#if data.canListTenants} - Elige la organización destino y el rol del usuario. - {:else} - Escribe el slug de tu organización y el rol del usuario. - {/if} - + Elige la compañía destino y el rol del usuario.
@@ -142,7 +131,7 @@
-
From 71be225b33c7cca81d93acd4d7a4106b71c1bf01 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Mon, 20 Jul 2026 06:43:35 -0600 Subject: [PATCH 19/40] fix(crm): guardar permisos de rol y recargar roles/usuarios tras refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - role-permissions.assign enviaba permission_id en el body pero el backend lo espera como query param → 422 silencioso; el permiso no se guardaba. Ahora va en la query y se confirma con un toast al marcar cada permiso (auto-guardado). - Roles y Usuarios recargaban solo con el evento companyChanged, que no dispara en la hidratación inicial; por eso tras refrescar no aparecían hasta re-elegir la compañía. Se cambia a un $effect que reacciona a la compañía activa. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/dashboard/admin/role-permissions.ts | 6 ++++- .../src/routes/dashboard/roles/+page.svelte | 23 ++++++++++--------- .../src/routes/dashboard/users/+page.svelte | 20 ++++++++-------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/api/dashboard/admin/role-permissions.ts b/frontend/src/lib/api/dashboard/admin/role-permissions.ts index 208ed61..ef057f7 100644 --- a/frontend/src/lib/api/dashboard/admin/role-permissions.ts +++ b/frontend/src/lib/api/dashboard/admin/role-permissions.ts @@ -48,7 +48,11 @@ export const rolePermissionsAPI = { companyId: number, data: AssignPermissionData ): Promise { - const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data); + // El backend recibe permission_id como query param (no en el body). + const response = await api.post( + `/v1/core/permissions/roles/${roleId}/permissions?permission_id=${data.permission_id}&company_id=${companyId}`, + {} + ); return response.data; }, diff --git a/frontend/src/routes/dashboard/roles/+page.svelte b/frontend/src/routes/dashboard/roles/+page.svelte index 0f60673..729ebd8 100644 --- a/frontend/src/routes/dashboard/roles/+page.svelte +++ b/frontend/src/routes/dashboard/roles/+page.svelte @@ -1,5 +1,4 @@ {#if tab === 'generales'} @@ -18,28 +26,32 @@ - - - + + + - + {:else if tab === 'comercial'}
- - - + + + {#if form.preferred_contact_method === 'otro'} + + {/if} + +
{:else if tab === 'fiscal'}
- - - - - + + + + + @@ -47,7 +59,7 @@
{:else if tab === 'observaciones'}
- +
{/if} diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index ec3f880..15ce0a5 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -8,10 +8,16 @@ addressesAPI, contactsAPI, documentsAPI, type Address, type Contact, type Document, type AddressInput, type ContactInput, type DocumentInput } from '$lib/api/crm'; - import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format'; + import { DOC_TYPES, labelOf } from '$lib/components/crm/format'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; + import { onMount } from 'svelte'; import { uploadFile, uploadUrl } from '$lib/api/uploads'; import { toast } from 'svelte-sonner'; + onMount(() => { + void crmCatalogs.preload(['tipo_domicilio', 'area', 'pais']); + }); + // Dueño de los registros relacionados y qué sección mostrar let { ownerType, @@ -31,7 +37,7 @@ let activeModal = $state<'address' | 'contact' | 'document' | null>(null); let saving = $state(false); - let addressForm = $state({ address_type: 'fiscal', country: 'MX', is_primary: false }); + let addressForm = $state({ address_type: 'fiscal', country: 'MEX', is_primary: false }); let contactForm = $state({ first_name: '' }); let documentForm = $state({ doc_type: 'constancia_fiscal', name: '' }); let uploading = $state(false); @@ -70,6 +76,11 @@ if (companyId && ownerId) void load(companyId); }); + // Estado depende del país seleccionado (catálogo dependiente) + $effect(() => { + if (addressForm.country) void crmCatalogs.ensure('estado', addressForm.country); + }); + async function load(cid: number) { try { [addresses, contacts, documents] = await Promise.all([ @@ -83,7 +94,7 @@ } function openModal(kind: 'address' | 'contact' | 'document') { - if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MX', is_primary: false }; + if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MEX', is_primary: false }; if (kind === 'contact') contactForm = { first_name: '' }; if (kind === 'document') documentForm = { doc_type: 'constancia_fiscal', name: '' }; activeModal = kind; @@ -174,7 +185,7 @@ {#each addresses as a (a.id)} - {labelOf(ADDRESS_TYPES, a.address_type)}{#if a.is_primary}(principal){/if} + {crmCatalogs.label('tipo_domicilio', a.address_type)}{#if a.is_primary}(principal){/if} {[a.street, a.ext_number, a.neighborhood].filter(Boolean).join(' ') || '—'} {a.postal_code ?? '—'} {[a.city, a.state].filter(Boolean).join(', ') || '—'} @@ -204,7 +215,7 @@ {#each contacts as c (c.id)} {c.first_name} {c.last_name ?? ''}{#if c.is_primary}(principal){/if} - {[c.job_title, labelOf(CONTACT_AREAS, c.area) !== '—' ? labelOf(CONTACT_AREAS, c.area) : null].filter(Boolean).join(' · ') || '—'} + {[c.job_title, c.area ? crmCatalogs.label('area', c.area) : null].filter(Boolean).join(' · ') || '—'} {c.email ?? '—'} {c.phone ?? c.mobile ?? '—'} @@ -252,15 +263,15 @@ {#if activeModal === 'address'}

Nueva dirección

- + - - - + + +
@@ -271,7 +282,7 @@ - + diff --git a/frontend/src/lib/components/crm/SupplierFields.svelte b/frontend/src/lib/components/crm/SupplierFields.svelte index 9a84ccf..ea7610b 100644 --- a/frontend/src/lib/components/crm/SupplierFields.svelte +++ b/frontend/src/lib/components/crm/SupplierFields.svelte @@ -1,6 +1,7 @@ {#if tab === 'generales'} @@ -28,25 +38,28 @@ - - + +
-

Clasificación (una o varias)

+

Clasificación del proveedor (una o varias)

- {#each SUPPLIER_CLASSIFICATIONS as c (c.value)} + {#each crmCatalogs.options('clasificacion_proveedor') as c (c.value)} {/each}
+ {#if hasOtro} + + {/if}
{:else if tab === 'comercial'}
- - - - - - + + + + + + @@ -56,16 +69,16 @@
{:else if tab === 'fiscal'}
- - - + + +
{:else if tab === 'observaciones'}
- +
{/if} diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 2206adc..2cca268 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -43,7 +43,7 @@ export const ACCOUNT_STATUS: Option[] = [ export const COMMERCIAL_CLASSIFICATION: Option[] = [ { value: 'importador', label: 'Importador' }, { value: 'exportador', label: 'Exportador' }, - { value: 'ambos', label: 'Importador / Exportador' } + { value: 'importador_exportador', label: 'Importador / Exportador' } ]; export const ACCOUNT_TYPES: Option[] = [ @@ -58,7 +58,7 @@ export const ACCOUNT_TYPES: Option[] = [ export const CONTACT_METHODS: Option[] = [ { value: 'llamada', label: 'Llamada telefónica' }, { value: 'correo', label: 'Correo electrónico' }, - { value: 'videollamada', label: 'Videoconferencia' }, + { value: 'videoconferencia', label: 'Videoconferencia' }, { value: 'whatsapp', label: 'WhatsApp' }, { value: 'otro', label: 'Otro' } ]; diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 0f2cce0..2e7bcd4 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -52,6 +52,7 @@ export function getNavMain(): NavMainItem[] { { title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' }, { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, { title: 'Actividades', url: '/dashboard/crm/actividades' }, + { title: 'Catálogos', url: '/dashboard/crm/catalogos' }, ], }, { diff --git a/frontend/src/lib/stores/crm-catalogs.svelte.ts b/frontend/src/lib/stores/crm-catalogs.svelte.ts new file mode 100644 index 0000000..8d4dcc7 --- /dev/null +++ b/frontend/src/lib/stores/crm-catalogs.svelte.ts @@ -0,0 +1,77 @@ +/** + * Store reactivo de catálogos de referencia del CRM. + * + * Carga desde el backend (/v1/crm/catalogs) y cachea por catálogo. Los + * formularios leen `options(catalog)` (reactivo) sin refetch. Los catálogos + * dependientes (Estado por País) se piden con `ensure(catalog, parentCode)`. + */ +import { referenceCatalogsAPI } from '$lib/api/crm/catalogs'; +import { companyStore } from '$lib/stores/company.svelte'; +import type { Option } from '$lib/components/crm/format'; + +class CrmCatalogStore { + private _cache = $state>({}); + private _pending = new Set(); + private _companyId: number | null = null; + + /** Toma la compañía activa; si cambió, limpia el caché. Devuelve su id (o null). */ + private syncCompany(): number | null { + const cid = companyStore.activeCompany?.id ?? null; + if (cid !== this._companyId) { + this._companyId = cid; + this._cache = {}; + this._pending.clear(); + } + return cid; + } + + private keyOf(catalog: string, parentCode?: string): string { + return parentCode ? `${catalog}:${parentCode}` : catalog; + } + + /** Opciones de un catálogo ya cargado (vacío si aún no se ha cargado). */ + options(catalog: string, parentCode?: string): Option[] { + return this._cache[this.keyOf(catalog, parentCode)] ?? []; + } + + /** Descripción de una clave (para listados/detalle). */ + label(catalog: string, code: string | null | undefined): string { + if (!code) return '—'; + return this.options(catalog).find((o) => o.value === code)?.label ?? code; + } + + /** Carga un catálogo (con dependiente opcional) si aún no está en caché. */ + async ensure(catalog: string, parentCode?: string): Promise { + const cid = this.syncCompany(); + const key = this.keyOf(catalog, parentCode); + if (cid == null || key in this._cache || this._pending.has(key)) return; + this._pending.add(key); + try { + const items = await referenceCatalogsAPI.list(catalog, cid, parentCode); + this._cache[key] = items.map((i) => ({ value: i.code, label: i.label })); + } catch { + this._cache[key] = []; + } finally { + this._pending.delete(key); + } + } + + /** Precarga varios catálogos globales en paralelo. */ + async preload(catalogs: string[]): Promise { + await Promise.all(catalogs.map((c) => this.ensure(c))); + } + + /** Invalida el caché de un catálogo (tras editar en administración). */ + invalidate(catalog?: string): void { + if (!catalog) { + this._cache = {}; + return; + } + for (const k of Object.keys(this._cache)) { + if (k === catalog || k.startsWith(`${catalog}:`)) delete this._cache[k]; + } + this._cache = { ...this._cache }; + } +} + +export const crmCatalogs = new CrmCatalogStore(); diff --git a/frontend/src/routes/dashboard/crm/catalogos/+page.svelte b/frontend/src/routes/dashboard/crm/catalogos/+page.svelte new file mode 100644 index 0000000..bb33ce5 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/catalogos/+page.svelte @@ -0,0 +1,217 @@ + + +
+
+

Catálogos

+

+ Administra las opciones de los catálogos del CRM. Los catálogos base del sistema (SAT/ISO) solo se pueden activar/desactivar; los de tu empresa se pueden insertar, editar y borrar. +

+
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} +
+ + Catálogos + + {#each metas as m (m.catalog)} + + {/each} + + + + + + {selected?.label ?? 'Opciones'} + + {#if selected} + {selected.scope === 'global' ? 'Catálogo global (Aduanasoft)' : 'Catálogo de tu empresa'} · {selected.count} opciones + {#if selected.is_system} · base del sistema (no se borra){/if} + {/if} + + + +
+ + + +
+ + {#if loading} +

Cargando…

+ {:else if items.length === 0} +

Sin opciones. Agrega la primera arriba.

+ {:else} +
+ + ClaveDescripciónOrigenActivoAcciones + + {#each items as it (it.id)} + + {it.code} + + {#if editId === it.id} + + {:else} + {it.label} + {/if} + + {it.tenant_id === null ? 'Global' : 'Empresa'}{#if it.is_system} · base{/if} + + {#if editId === it.id} + + {:else} + {it.is_active ? 'Sí' : 'No'} + {/if} + + + {#if editId === it.id} + + + {:else} + + {#if !it.is_system} + + {/if} + {/if} + + + {/each} + + +
+ {/if} +
+
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/crm/cuentas/+page.svelte b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte index d9a1b5f..fcd59b1 100644 --- a/frontend/src/routes/dashboard/crm/cuentas/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cuentas/+page.svelte @@ -1,5 +1,5 @@ + +
+
+

Cotizador

+

Calcula el costo por proveedor a partir de los tarifarios vigentes.

+
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} + + Datos de la carga + +
+ + + + + {#if isFcl} + + + {:else} + + + {/if} + + + +
+
+
+
+ + {#if calculated} + + Opciones ({options.length}) + Ordenadas por costo total. El precio de venta se define en la cotización (costo + margen). + + + {#if options.length === 0} +

No hay tarifas vigentes para esa ruta/modo. Verifica que exista un tarifario activo con esa ruta.

+ {:else} +
+ + TarifarioBaseCargosTotalDetalleTránsito + + {#each options as o, i (o.rate_sheet_id + '-' + i)} + + {o.rate_sheet_name} + {money(o.base_cost, o.currency)} + {o.charges.length ? o.charges.map((c) => `${c.concept}: ${money(c.amount, o.currency)}`).join(', ') : '—'} + {money(o.total_cost, o.currency)} + {o.detail ?? '—'} + {o.transit_days ?? '—'} + + {/each} + + +
+ {/if} +
+
+ {/if} + {/if} +
diff --git a/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte new file mode 100644 index 0000000..4bb981a --- /dev/null +++ b/frontend/src/routes/dashboard/crm/tarifarios/+page.svelte @@ -0,0 +1,179 @@ + + +
+
+
+

Tarifarios

+

Costos de proveedores por ruta, base de las cotizaciones.

+
+ +
+ + + +
+ +
+
+ + {#if loading} +

Cargando…

+ {:else if sheets.length === 0} +

Sin tarifarios. Importa uno con el botón de arriba.

+ {:else} +
+ + NombreModoMonedaRutasVigenciaEstatusAcciones + + {#each sheets as s (s.id)} + + {s.name} + {modeLabel(s.mode)} + {s.currency ?? '—'} + {s.lane_count ?? 0} + {s.valid_from ? formatDate(s.valid_from) : '—'} → {s.valid_to ? formatDate(s.valid_to) : '—'} + {s.status} + + + + + + {/each} + + +
+ {/if} +
+
+
+ +{#if showImport} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte new file mode 100644 index 0000000..41190f9 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte @@ -0,0 +1,97 @@ + + +
+ + {#if loading && !sheet} +

Cargando…

+ {:else if sheet} +
+
+

{sheet.name}

+

+ {crmCatalogs.label('modo_tarifario', sheet.mode)} · {sheet.currency ?? '—'} · {lanes.length} rutas + · vigencia {sheet.valid_from ? formatDate(sheet.valid_from) : '—'} → {sheet.valid_to ? formatDate(sheet.valid_to) : '—'} + · estatus {sheet.status} +

+
+
+ {#if sheet.status !== 'activo'} + + {:else} + + {/if} +
+
+ + + Rutas ({lanes.length}) + El motor de cotización usa estas rutas cuando el tarifario está activo y vigente. + + + {#if lanes.length === 0} +

Sin rutas.

+ {:else} +
+ + RegiónOrigenDestinoEquipoMínimoTarifa / QuiebresTránsito + + {#each lanes as l (l.id)} + + {l.region ?? '—'} + {l.origin ?? sheet.default_origin ?? '—'} + {l.destination ?? '—'} + {eq(l.equipment_type)} + {l.min_charge ?? '—'} + {l.flat_rate != null ? l.flat_rate : breaksTxt(l)} + {l.transit_days ?? '—'} + + + {/each} + + +
+ {/if} +
+
+ {/if} +
From ef7e69ed57f0b65cca40baa814ff62ebf10c03cc Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Mon, 27 Jul 2026 09:46:25 -0600 Subject: [PATCH 27/40] =?UTF-8?q?feat(crm):=20tarifario=20=E2=80=94=20edit?= =?UTF-8?q?or=20de=20cargos=20adicionales=20+=20alta=20manual=20de=20rutas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: CRUD de cargos (rate_charges) por tarifario. - Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/crm/rates/dto.py | 26 +++ backend/api/v1/modules/crm/rates/routes.py | 53 ++++++ backend/api/v1/modules/crm/rates/service.py | 54 ++++++ frontend/src/lib/api/crm/rates.ts | 9 + .../crm/tarifarios/[id]/+page.svelte | 172 +++++++++++++++++- 5 files changed, 306 insertions(+), 8 deletions(-) diff --git a/backend/api/v1/modules/crm/rates/dto.py b/backend/api/v1/modules/crm/rates/dto.py index 7daf47a..a5aec50 100644 --- a/backend/api/v1/modules/crm/rates/dto.py +++ b/backend/api/v1/modules/crm/rates/dto.py @@ -21,6 +21,32 @@ class RateChargeDTO(BaseModel): condition: str | None = None +class RateChargeCreate(BaseModel): + concept: str = Field(..., max_length=60) + charge_type: str = Field("fijo", max_length=20) + value: Decimal | None = None + condition: str | None = None + rate_lane_id: int | None = None + + +class RateChargeUpdate(BaseModel): + concept: str | None = Field(None, max_length=60) + charge_type: str | None = Field(None, max_length=20) + value: Decimal | None = None + condition: str | None = None + + +class RateChargeResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + rate_sheet_id: int | None + rate_lane_id: int | None + concept: str + charge_type: str + value: Decimal | None + condition: str | None + + # ---------- Rutas ---------- class RateLaneBase(BaseModel): origin: str | None = Field(None, max_length=20) diff --git a/backend/api/v1/modules/crm/rates/routes.py b/backend/api/v1/modules/crm/rates/routes.py index 7fb32d5..28d46e7 100644 --- a/backend/api/v1/modules/crm/rates/routes.py +++ b/backend/api/v1/modules/crm/rates/routes.py @@ -14,6 +14,9 @@ from .dto import ( CostResult, ImportPreview, RateBreakDTO, + RateChargeCreate, + RateChargeResponse, + RateChargeUpdate, RateLaneCreate, RateLaneResponse, RateSheetCreate, @@ -187,6 +190,56 @@ def delete_lane( service.delete_lane(db, tenant_id, sheet_id, lane_id) +# ---------------- Cargos adicionales ---------------- +@router.get("/{sheet_id}/charges", response_model=list[RateChargeResponse]) +def list_charges( + sheet_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + service.get_sheet(db, tenant_id, company_id, sheet_id) + return service.list_charges(db, tenant_id, sheet_id) + + +@router.post("/{sheet_id}/charges", response_model=RateChargeResponse, status_code=status.HTTP_201_CREATED) +def create_charge( + sheet_id: int, + data: RateChargeCreate, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + return service.create_charge(db, tenant_id, company_id, sheet_id, data) + + +@router.patch("/{sheet_id}/charges/{charge_id}", response_model=RateChargeResponse) +def update_charge( + sheet_id: int, + charge_id: int, + data: RateChargeUpdate, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + return service.update_charge(db, tenant_id, sheet_id, charge_id, data) + + +@router.delete("/{sheet_id}/charges/{charge_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_charge( + sheet_id: int, + charge_id: int, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + tenant_id, _ = _ctx(current_user) + service.delete_charge(db, tenant_id, sheet_id, charge_id) + + # ---------------- Motor de costeo ---------------- cost_router = APIRouter(tags=["Tarifario"]) diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py index 49f15c1..8804e30 100644 --- a/backend/api/v1/modules/crm/rates/service.py +++ b/backend/api/v1/modules/crm/rates/service.py @@ -149,6 +149,60 @@ def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> Non db.commit() +# ============================================================ Cargos adicionales +def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]: + return ( + db.query(RateCharge) + .filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id, + RateCharge.deleted_at.is_(None)) + .order_by(RateCharge.concept) + .all() + ) + + +def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge: + get_sheet(db, tenant_id, company_id, sheet_id) + ch = RateCharge( + tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, + rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type, + value=data.value, condition=data.condition, + ) + db.add(ch) + db.commit() + db.refresh(ch) + return ch + + +def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge: + ch = ( + db.query(RateCharge) + .filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id, + RateCharge.tenant_id == tenant_id) + .first() + ) + if not ch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(ch, field, value) + db.commit() + db.refresh(ch) + return ch + + +def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None: + from sqlalchemy import func + ch = ( + db.query(RateCharge) + .filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id, + RateCharge.tenant_id == tenant_id) + .first() + ) + if not ch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado") + ch.deleted_at = func.now() + db.commit() + + # ============================================================ Importación Excel # Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre). TEMPLATES: dict[str, list[str]] = { diff --git a/frontend/src/lib/api/crm/rates.ts b/frontend/src/lib/api/crm/rates.ts index 4a3ba52..9c4c8dd 100644 --- a/frontend/src/lib/api/crm/rates.ts +++ b/frontend/src/lib/api/crm/rates.ts @@ -24,6 +24,12 @@ export type RateSheetInput = Partial; ok: boolean; warnings: string[]; errors: string[]; } export interface ImportPreview { mode: RateMode; total: number; valid: number; rows: ImportPreviewRow[]; columns: string[]; } @@ -61,6 +67,9 @@ export const rateSheetsAPI = { lanes: (id: number, companyId: number) => unwrap(api.get(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`)), addLane: (id: number, data: Partial, companyId: number) => unwrap(api.post(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`, data)), removeLane: (id: number, laneId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/lanes/${laneId}?${qp(companyId)}`)), + charges: (id: number, companyId: number) => unwrap(api.get(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`)), + addCharge: (id: number, data: RateChargeInput, companyId: number) => unwrap(api.post(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`, data)), + removeCharge: (id: number, chargeId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/charges/${chargeId}?${qp(companyId)}`)), /** Descarga la plantilla Excel del modo. */ async downloadTemplate(mode: RateMode, companyId: number): Promise { diff --git a/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte index 41190f9..0b123fa 100644 --- a/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte @@ -1,29 +1,48 @@
@@ -64,12 +135,15 @@
- Rutas ({lanes.length}) - El motor de cotización usa estas rutas cuando el tarifario está activo y vigente. + +
Rutas ({lanes.length}) + El motor de cotización usa estas rutas cuando el tarifario está activo y vigente. +
+
{#if lanes.length === 0} -

Sin rutas.

+

Sin rutas. Agrega una o importa por Excel.

{:else}
@@ -93,5 +167,87 @@ {/if} + + + Cargos adicionales ({charges.length}) + Recargos que el motor suma a la tarifa base (combustible, DGR, THC, maniobras…). + + +
+ + + + + +
+ {#if charges.length === 0} +

Sin cargos adicionales.

+ {:else} + + ConceptoTipoValorCondición + + {#each charges as c (c.id)} + + {conceptLabel(c.concept)} + {chargeTypeLabel(c.charge_type)} + {c.value ?? '—'}{c.charge_type === 'porcentaje' ? ' %' : ''} + {c.condition ?? '—'} + + + {/each} + + + {/if} +
+
{/if}
+ +{#if showLane && sheet} + +{/if} From 206ff450f8d86c94ee03c37b08ebdbb42a0736c1 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Wed, 29 Jul 2026 13:18:07 -0600 Subject: [PATCH 28/40] =?UTF-8?q?feat(crm):=20PDF=20de=20cotizaci=C3=B3n?= =?UTF-8?q?=20(formato=20maestro)=20+=20marca=20por=20tenant=20+=20env?= =?UTF-8?q?=C3=ADo=20por=20correo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modelo crm.quote_settings (emisor, logo, color, prefijo, términos) por compañía + columna quotes.pdf_file_key + migración con down(). - Generador PDF (formato maestro: emisor+logo, cliente, carga/ruta, costos, resumen, condiciones); logo incrustado como JPEG (Pillow). - Endpoints: GET /quotes/{id}/pdf-url, POST /quotes/{id}/send-email (adjunta PDF), GET/PUT /quote-settings, POST /quote-settings/logo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../a0b1c2d3e4f5_quote_settings_and_pdf.py | 60 ++++ backend/api/v1/modules/crm/quotes/dto.py | 28 ++ backend/api/v1/modules/crm/quotes/models.py | 25 ++ backend/api/v1/modules/crm/quotes/pdf.py | 282 ++++++++++++++++++ .../api/v1/modules/crm/quotes/pdf_service.py | 235 +++++++++++++++ backend/api/v1/modules/crm/quotes/routes.py | 84 +++++- 6 files changed, 712 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/a0b1c2d3e4f5_quote_settings_and_pdf.py create mode 100644 backend/api/v1/modules/crm/quotes/pdf.py create mode 100644 backend/api/v1/modules/crm/quotes/pdf_service.py diff --git a/backend/alembic/versions/a0b1c2d3e4f5_quote_settings_and_pdf.py b/backend/alembic/versions/a0b1c2d3e4f5_quote_settings_and_pdf.py new file mode 100644 index 0000000..3f91f0d --- /dev/null +++ b/backend/alembic/versions/a0b1c2d3e4f5_quote_settings_and_pdf.py @@ -0,0 +1,60 @@ +"""crm quote_settings (marca por tenant) + quotes.pdf_file_key + +Revision ID: a0b1c2d3e4f5 +Revises: f8a9b0c1d2e3 +Create Date: 2026-07-29 00:00:00.000000 + +PDF de cotización con formato maestro + branding por tenant + envío por correo. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "a0b1c2d3e4f5" +down_revision: Union[str, None] = "f8a9b0c1d2e3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column("quotes", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema=SCHEMA) + + op.create_table( + "quote_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("emitter_name", sa.String(length=255), nullable=True), + sa.Column("emitter_rfc", sa.String(length=13), nullable=True), + sa.Column("emitter_address", sa.Text(), nullable=True), + sa.Column("emitter_phone", sa.String(length=60), nullable=True), + sa.Column("emitter_email", sa.String(length=255), nullable=True), + sa.Column("emitter_website", sa.String(length=255), nullable=True), + sa.Column("logo_file_key", sa.String(length=512), nullable=True), + sa.Column("accent_color", sa.String(length=9), nullable=True, server_default=sa.text("'#2f6bf0'")), + sa.Column("quote_prefix", sa.String(length=12), nullable=True, server_default=sa.text("'COT'")), + sa.Column("default_terms", sa.Text(), nullable=True), + sa.Column("footer_note", sa.Text(), nullable=True), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + schema=SCHEMA, + ) + op.create_index("ix_crm_quote_settings_id", "quote_settings", ["id"], schema=SCHEMA) + op.create_index("ix_crm_quote_settings_tenant_id", "quote_settings", ["tenant_id"], schema=SCHEMA) + op.create_index("ix_crm_quote_settings_company_id", "quote_settings", ["company_id"], schema=SCHEMA) + # Una configuración por compañía + op.create_index( + "uq_crm_quote_settings_company", "quote_settings", ["tenant_id", "company_id"], + unique=True, schema=SCHEMA, postgresql_where=sa.text("deleted_at IS NULL"), + ) + + +def downgrade() -> None: + op.drop_table("quote_settings", schema=SCHEMA) + op.drop_column("quotes", "pdf_file_key", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/quotes/dto.py b/backend/api/v1/modules/crm/quotes/dto.py index 3ff1387..5c74b7d 100644 --- a/backend/api/v1/modules/crm/quotes/dto.py +++ b/backend/api/v1/modules/crm/quotes/dto.py @@ -86,6 +86,7 @@ class QuoteResponse(QuoteBase): status: str total_cost: Decimal total_sale: Decimal + pdf_file_key: str | None = None sent_at: datetime | None = None accepted_at: datetime | None = None rejected_at: datetime | None = None @@ -100,3 +101,30 @@ class QuoteResponse(QuoteBase): @property def margin(self) -> Decimal: return (self.total_sale or Decimal(0)) - (self.total_cost or Decimal(0)) + + +# ----- Configuración de marca del formato de cotización ----- + +class QuoteSettingsInput(BaseModel): + emitter_name: str | None = Field(None, max_length=255) + emitter_rfc: str | None = Field(None, max_length=13) + emitter_address: str | None = None + emitter_phone: str | None = Field(None, max_length=60) + emitter_email: str | None = Field(None, max_length=255) + emitter_website: str | None = Field(None, max_length=255) + accent_color: str | None = Field(None, max_length=9) + quote_prefix: str | None = Field(None, max_length=12) + default_terms: str | None = None + footer_note: str | None = None + + +class QuoteSettingsResponse(QuoteSettingsInput): + model_config = ConfigDict(from_attributes=True) + id: int | None = None + logo_file_key: str | None = None + + +class SendQuoteEmailRequest(BaseModel): + to: str | None = None + subject: str | None = None + message: str | None = None diff --git a/backend/api/v1/modules/crm/quotes/models.py b/backend/api/v1/modules/crm/quotes/models.py index ee3ff16..cf86854 100644 --- a/backend/api/v1/modules/crm/quotes/models.py +++ b/backend/api/v1/modules/crm/quotes/models.py @@ -34,10 +34,35 @@ class Quote(Base, TenantScopedMixin, TimestampMixin): notes: Mapped[str | None] = mapped_column(Text, nullable=True) terms: Mapped[str | None] = mapped_column(Text, nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + # Clave del PDF generado en MinIO (para regenerar/enviar) + pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True) created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) +class QuoteSettings(Base, TenantScopedMixin, TimestampMixin): + """Configuración de marca del formato de cotización, por compañía (tenant). + + Encabezado del emisor, logo y textos por defecto que se imprimen en el PDF. + """ + + __tablename__ = "quote_settings" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + emitter_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + emitter_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True) + emitter_address: Mapped[str | None] = mapped_column(Text, nullable=True) + emitter_phone: Mapped[str | None] = mapped_column(String(60), nullable=True) + emitter_email: Mapped[str | None] = mapped_column(String(255), nullable=True) + emitter_website: Mapped[str | None] = mapped_column(String(255), nullable=True) + logo_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True) + accent_color: Mapped[str | None] = mapped_column(String(9), nullable=True, server_default=text("'#2f6bf0'")) + quote_prefix: Mapped[str | None] = mapped_column(String(12), nullable=True, server_default=text("'COT'")) + default_terms: Mapped[str | None] = mapped_column(Text, nullable=True) + footer_note: Mapped[str | None] = mapped_column(Text, nullable=True) + + class QuoteItem(Base, TenantScopedMixin, TimestampMixin): """Concepto de una cotización (flete, transporte terrestre, despacho, gastos destino, otros).""" diff --git a/backend/api/v1/modules/crm/quotes/pdf.py b/backend/api/v1/modules/crm/quotes/pdf.py new file mode 100644 index 0000000..7caebfc --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/pdf.py @@ -0,0 +1,282 @@ +"""Generador del PDF de Cotización (formato maestro) sin dependencias de sistema. + +Compone un PDF 1.4 válido byte a byte (fuente Helvetica) e incrusta el logo como +imagen JPEG (XObject /DCTDecode) usando Pillow para normalizarlo. El branding del +emisor (nombre, RFC, dirección, contacto, color) viene de la configuración por +tenant. +""" + +from __future__ import annotations + +import io +from decimal import Decimal + +_PAGE_W = 612 +_PAGE_H = 792 +_MARGIN = 50 + +CONCEPT_LABELS = { + "flete_internacional": "Flete internacional", + "transporte_terrestre": "Transporte terrestre", + "despacho_aduanal": "Despacho aduanal", + "gastos_destino": "Gastos en destino", + "otros": "Otros cargos", +} + + +def _esc(text: str) -> str: + out = (str(text) if text is not None else "").encode("latin-1", "replace").decode("latin-1") + return out.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") + + +def _money(value, currency: str = "") -> str: + d = Decimal(str(value or 0)).quantize(Decimal("0.01")) + return (f"{currency} " if currency else "") + f"{d:,.2f}" + + +def _wrap(text: str, width: int) -> list[str]: + words = (text or "").split() + if not words: + return [] + lines, cur = [], "" + for w in words: + cand = f"{cur} {w}".strip() + if len(cand) > width and cur: + lines.append(cur) + cur = w + else: + cur = cand + if cur: + lines.append(cur) + return lines + + +def _hex_rgb(hexs: str | None) -> tuple[float, float, float]: + try: + h = (hexs or "#2f6bf0").lstrip("#") + return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] + except Exception: + return (0.184, 0.42, 0.94) + + +def _prep_logo(logo_bytes: bytes | None): + """Normaliza el logo a JPEG RGB. Devuelve (jpeg_bytes, w, h) o None.""" + if not logo_bytes: + return None + try: + from PIL import Image + + im = Image.open(io.BytesIO(logo_bytes)).convert("RGB") + im.thumbnail((600, 300)) + buf = io.BytesIO() + im.save(buf, format="JPEG", quality=85) + return buf.getvalue(), im.width, im.height + except Exception: + return None + + +def _kv_lines(pairs: list[tuple[str, str]]) -> list[tuple[str, int]]: + out: list[tuple[str, int]] = [] + for k, v in pairs: + if v not in (None, "", "None"): + out.append((f"{k}: {v}", 10)) + return out + + +def build_quote_pdf( + *, + emitter: dict, + head: dict, + client: dict, + cargo: list[tuple[str, str]], + route: list[tuple[str, str]], + items: list[dict], + currency: str, + subtotal, + terms: str | None, + footer: str | None, + logo_bytes: bytes | None = None, + accent: str | None = "#2f6bf0", +) -> bytes: + accent_rgb = _hex_rgb(accent) + logo = _prep_logo(logo_bytes) + + # ---- Cuerpo (debajo del encabezado) ---- + body: list[tuple[str, int]] = [] + + def section(title: str): + body.append(("", 6)) + body.append((title.upper(), 11)) + body.append(("_" * 92, 8)) + + # Cliente + section("Cliente") + for line in _kv_lines([ + ("Cliente", client.get("name")), ("RFC", client.get("rfc")), + ("Correo", client.get("email")), ("Teléfono", client.get("phone")), + ]): + body.append(line) + + # Carga / Ruta + if cargo: + section("Información de la carga") + for line in _kv_lines(cargo): + body.append(line) + if route: + section("Ruta logística") + for line in _kv_lines(route): + body.append(line) + + # Costos + section("Costos cotizados") + body.append(("Concepto Cant. Tarifa Importe", 9)) + body.append(("-" * 92, 8)) + for it in items: + code = str(it.get("concept") or "") + label = CONCEPT_LABELS.get(code, code) + desc = str(it.get("description") or "") + if desc: + label = f"{label} — {desc}" + qty = Decimal(str(it.get("quantity") or 0)) + unit = Decimal(str(it.get("unit_sale") or 0)) + amount = (qty * unit).quantize(Decimal("0.01")) + row = f"{label[:40].ljust(40)} {qty:>6.2f} {unit:>14,.2f} {amount:>14,.2f}" + body.append((row, 9)) + body.append(("-" * 92, 8)) + body.append((f"Subtotal {currency}: {_money(subtotal)}", 11)) + body.append(("IVA: según aplique", 9)) + body.append((f"Total {currency}: {_money(subtotal)} + IVA", 12)) + + # Condiciones + if terms: + section("Condiciones comerciales") + for para in terms.splitlines(): + for line in _wrap(para, 105) or [""]: + body.append((line, 9)) + + # ---- Paginación (página 1 con encabezado; siguientes solo cuerpo) ---- + p1_top = _PAGE_H - 150 # y donde inicia el cuerpo en la página 1 + pN_top = _PAGE_H - _MARGIN + line_h = 14 + pages: list[list[tuple[float, tuple[str, int]]]] = [] + cur: list[tuple[float, tuple[str, int]]] = [] + y = p1_top + for item in body: + if y < _MARGIN + 40: + pages.append(cur) + cur = [] + y = pN_top + cur.append((y, item)) + y -= line_h + pages.append(cur) + + # ---- Content streams ---- + streams: list[bytes] = [] + for pi, page in enumerate(pages): + parts: list[str] = [] + if pi == 0: + # barra de acento arriba + r, g, b = accent_rgb + parts.append(f"{r:.3f} {g:.3f} {b:.3f} rg") + parts.append(f"0 {_PAGE_H - 8} {_PAGE_W} 8 re f") + # logo + logo_y = _PAGE_H - 30 + if logo: + _, lw, lh = logo + dw = 150.0 + dh = dw * lh / lw + if dh > 60: + dh = 60.0 + dw = dh * lw / lh + parts.append(f"q {dw:.2f} 0 0 {dh:.2f} {_MARGIN} {logo_y - dh:.2f} cm /Im0 Do Q") + # emisor (columna derecha) + ex = 330 + ey = logo_y - 6 + parts.append("BT /F1 12 Tf 0.09 0.14 0.24 rg") + parts.append(f"1 0 0 1 {ex} {ey} Tm ({_esc(emitter.get('name') or 'Emisor')}) Tj") + parts.append("/F1 9 Tf 0.35 0.41 0.5 rg 13 TL") + em_lines = [] + if emitter.get("rfc"): + em_lines.append(f"RFC: {emitter['rfc']}") + for a in (emitter.get("address") or "").splitlines(): + if a.strip(): + em_lines.append(a.strip()) + contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x]) + if contact: + em_lines.append(contact) + for ln in em_lines[:5]: + parts.append(f"T* ({_esc(ln)}) Tj") + parts.append("ET") + # título + parts.append("BT /F1 22 Tf 0.09 0.14 0.24 rg") + parts.append(f"1 0 0 1 {_MARGIN} {_PAGE_H - 120} Tm (COTIZACION) Tj ET") + # datos de cabecera (derecha del título) + parts.append("BT /F1 9 Tf 0.2 0.25 0.35 rg 12 TL") + parts.append(f"1 0 0 1 330 {_PAGE_H - 100} Tm ({_esc('No.: ' + str(head.get('reference') or '-'))}) Tj") + for hl in [ + f"Fecha: {head.get('issue_date') or '-'}", + f"Vigencia: {head.get('valid_until') or '-'}", + f"Ejecutivo: {head.get('owner') or '-'} Estatus: {head.get('status') or '-'}", + ]: + parts.append(f"T* ({_esc(hl)}) Tj") + parts.append("ET") + # cuerpo + for yy, (text, size) in page: + parts.append(f"BT /F1 {size} Tf 0 0 0 rg 1 0 0 1 {_MARGIN} {yy:.2f} Tm ({_esc(text)}) Tj ET") + # pie + if footer: + parts.append(f"BT /F1 8 Tf 0.5 0.5 0.5 rg 1 0 0 1 {_MARGIN} {_MARGIN - 20} Tm ({_esc(footer[:110])}) Tj ET") + streams.append("\n".join(parts).encode("latin-1", "replace")) + + # ---- Ensamblado de objetos ---- + objects: list[bytes] = [] + + def add(obj: bytes) -> int: + objects.append(obj) + return len(objects) + + n_pages = len(pages) + has_img = 1 if logo else 0 + font_num = 3 + img_num = 4 if has_img else None + base = 5 if has_img else 4 + page_nums = list(range(base, base + n_pages)) + content_nums = list(range(base + n_pages, base + 2 * n_pages)) + + kids = " ".join(f"{n} 0 R" for n in page_nums) + add(b"<< /Type /Catalog /Pages 2 0 R >>") + add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1")) + add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + if logo: + jpeg, lw, lh = logo + img_obj = ( + f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} " + f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n" + ).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream" + add(img_obj) + for i in range(n_pages): + res = f"/Font << /F1 {font_num} 0 R >>" + if has_img and i == 0: + res += f" /XObject << /Im0 {img_num} 0 R >>" + page_dict = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] " + f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>" + ) + add(page_dict.encode("latin-1")) + for stream in streams: + add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream") + + out = bytearray() + out += b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" + offsets: list[int] = [] + for i, obj in enumerate(objects, start=1): + offsets.append(len(out)) + out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n" + xref_pos = len(out) + total = len(objects) + 1 + out += f"xref\n0 {total}\n".encode("latin-1") + out += b"0000000000 65535 f \n" + for off in offsets: + out += f"{off:010d} 00000 n \n".encode("latin-1") + out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1") + return bytes(out) diff --git a/backend/api/v1/modules/crm/quotes/pdf_service.py b/backend/api/v1/modules/crm/quotes/pdf_service.py new file mode 100644 index 0000000..2225090 --- /dev/null +++ b/backend/api/v1/modules/crm/quotes/pdf_service.py @@ -0,0 +1,235 @@ +"""PDF de cotización, configuración de marca por tenant y envío por correo.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from fastapi import HTTPException, status +from sqlalchemy import text +from sqlalchemy.orm import Session + +from ..accounts.models import Account +from ..service_requests.models import ServiceRequest +from .models import Quote, QuoteItem, QuoteSettings +from .pdf import build_quote_pdf +from .service import get_quote + +logger = logging.getLogger(__name__) + +DEFAULT_TERMS = ( + "Tarifas sujetas a disponibilidad de espacio.\n" + "Cualquier variación en peso o volumen generará ajuste tarifario.\n" + "No incluye cargos extraordinarios, maniobras especiales o servicios no especificados.\n" + "Tarifas sujetas a revisión por parte de la línea transportista y autoridades correspondientes." +) + + +# ---------------- Configuración de marca ---------------- +def get_settings(db: Session, tenant_id: int, company_id: int) -> QuoteSettings | None: + return ( + db.query(QuoteSettings) + .filter(QuoteSettings.tenant_id == tenant_id, QuoteSettings.company_id == company_id, + QuoteSettings.deleted_at.is_(None)) + .first() + ) + + +def upsert_settings(db: Session, tenant_id: int, company_id: int, data: dict) -> QuoteSettings: + obj = get_settings(db, tenant_id, company_id) + if obj is None: + obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id) + db.add(obj) + for field, value in data.items(): + if value is not None: + setattr(obj, field, value) + db.commit() + db.refresh(obj) + return obj + + +def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) -> QuoteSettings: + obj = get_settings(db, tenant_id, company_id) + if obj is None: + obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id) + db.add(obj) + obj.logo_file_key = file_key + db.commit() + db.refresh(obj) + return obj + + +def _company_row(db: Session, company_id: int) -> dict: + try: + row = db.execute( + text("SELECT name, rfc, logo FROM a76.company WHERE id = :c"), {"c": company_id} + ).first() + if row: + return {"name": row[0], "rfc": row[1], "logo": row[2]} + except Exception: + pass + return {} + + +# ---------------- Construcción del PDF ---------------- +def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) -> bytes: + items = ( + db.query(QuoteItem) + .filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)) + .order_by(QuoteItem.id.asc()) + .all() + ) + account = ( + db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None + ) + sr = ( + db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first() + if quote.service_request_id else None + ) + settings = get_settings(db, tenant_id, company_id) + company = _company_row(db, company_id) + + # Emisor: config del tenant con respaldo en a76.company + emitter = { + "name": (settings.emitter_name if settings else None) or company.get("name") or "Emisor", + "rfc": (settings.emitter_rfc if settings else None) or company.get("rfc"), + "address": settings.emitter_address if settings else None, + "phone": settings.emitter_phone if settings else None, + "email": settings.emitter_email if settings else None, + "website": settings.emitter_website if settings else None, + } + accent = (settings.accent_color if settings else None) or "#2f6bf0" + prefix = (settings.quote_prefix if settings else None) or "COT" + terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS + footer = settings.footer_note if settings else None + + # Logo (MinIO) + logo_bytes = None + logo_key = settings.logo_file_key if settings else None + if logo_key: + try: + from core.storage_s3 import get_object_bytes + logo_bytes = get_object_bytes(logo_key) + except Exception as exc: + logger.warning("No se pudo leer el logo del tarifario: %s", exc) + + reference = quote.reference or f"{prefix}-{datetime.now().strftime('%Y%m%d')}-{quote.id:03d}" + head = { + "reference": reference, + "issue_date": quote.issue_date.isoformat() if quote.issue_date else None, + "valid_until": quote.valid_until.isoformat() if quote.valid_until else None, + "owner": quote.owner_user_id or "-", + "status": quote.status, + } + client = { + "name": account.name if account else None, + "rfc": account.rfc if account else None, + "email": account.email if account else None, + "phone": account.phone if account else None, + } + cargo = [] + route = [] + if sr: + cargo = [ + ("Tipo de mercancía", sr.cargo_type), ("Descripción", sr.commodity), + ("Peso", str(sr.weight) if sr.weight is not None else None), + ("Volumen", str(sr.volume) if sr.volume is not None else None), + ("Tipo de carga", sr.load_type), ("Equipo", sr.container_equipment), + ] + route = [ + ("Operación", sr.operation_type), ("Modo", sr.transport_mode), + ("Servicio", sr.service_type), ("Incoterm", sr.incoterm), + ("Origen", sr.origin), ("Destino", sr.destination), + ("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None), + ] + + return build_quote_pdf( + emitter=emitter, head=head, client=client, cargo=cargo, route=route, + items=[{"concept": i.concept, "description": i.description, "quantity": i.quantity, "unit_sale": i.unit_sale} for i in items], + currency=quote.currency, subtotal=quote.total_sale, terms=terms, footer=footer, + logo_bytes=logo_bytes, accent=accent, + ) + + +def _store_pdf(db: Session, quote: Quote, tenant_id: int, company_id: int, pdf_bytes: bytes) -> str: + from core.storage_s3 import put_object_bytes + ref = (quote.reference or f"cot-{quote.id}").replace("/", "-") + key = f"tenants/{tenant_id}/companies/{company_id}/crm-quotes/{quote.id}/cotizacion-{ref}.pdf" + put_object_bytes(key, pdf_bytes, content_type="application/pdf") + quote.pdf_file_key = key + db.commit() + return key + + +def get_pdf_url(db: Session, quote_id: int, tenant_id: int, company_id: int) -> str: + from core.storage_s3 import presigned_get_url + quote = get_quote(db, quote_id, tenant_id, company_id) + pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id) + key = _store_pdf(db, quote, tenant_id, company_id, pdf_bytes) + return presigned_get_url(key) + + +# ---------------- Envío por correo ---------------- +async def send_quote_email( + db: Session, quote_id: int, tenant_id: int, company_id: int, + to: str | None, subject: str | None, message: str | None, +) -> dict: + import ssl + from email import encoders + from email.mime.base import MIMEBase + from email.mime.multipart import MIMEMultipart + from email.mime.text import MIMEText + + import aiosmtplib + + from core.config import settings as cfg + + quote = get_quote(db, quote_id, tenant_id, company_id) + account = db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None + recipient = to or (account.email if account else None) + if not recipient: + raise HTTPException(status_code=400, detail="No hay correo destino (captura uno o pon el correo del cliente).") + + pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id) + _store_pdf(db, quote, tenant_id, company_id, pdf_bytes) + ref = quote.reference or f"COT-{quote.id}" + + msg = MIMEMultipart() + msg["From"] = f"{cfg.SMTP_FROM_NAME} <{cfg.SMTP_USER}>" + msg["To"] = recipient + msg["Subject"] = subject or f"Cotización {ref}" + html = ( + "
" + f"

{(message or 'Adjunto la cotización solicitada. Quedamos atentos.').replace(chr(10), '
')}

" + f"

Cotización {ref}

" + ) + msg.attach(MIMEText(html, "html")) + part = MIMEBase("application", "pdf") + part.set_payload(pdf_bytes) + encoders.encode_base64(part) + part.add_header("Content-Disposition", f'attachment; filename="cotizacion-{ref}.pdf"') + msg.attach(part) + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + try: + if cfg.SMTP_PORT == 465: + async with aiosmtplib.SMTP(hostname=cfg.SMTP_HOST, port=cfg.SMTP_PORT, use_tls=True, tls_context=ctx) as smtp: + await smtp.login(cfg.SMTP_USER, cfg.SMTP_PASSWORD) + await smtp.send_message(msg) + else: + async with aiosmtplib.SMTP(hostname=cfg.SMTP_HOST, port=cfg.SMTP_PORT, tls_context=ctx) as smtp: + await smtp.starttls(tls_context=ctx) + await smtp.login(cfg.SMTP_USER, cfg.SMTP_PASSWORD) + await smtp.send_message(msg) + except Exception as exc: + logger.error("Error enviando cotización %s: %s", quote_id, exc) + raise HTTPException(status_code=502, detail=f"No se pudo enviar el correo: {exc}") + + # Marca como enviada + if quote.status == "borrador": + quote.status = "enviada" + quote.sent_at = datetime.now(timezone.utc) + db.commit() + return {"sent_to": recipient, "reference": ref} diff --git a/backend/api/v1/modules/crm/quotes/routes.py b/backend/api/v1/modules/crm/quotes/routes.py index ccc3138..a5b38d5 100644 --- a/backend/api/v1/modules/crm/quotes/routes.py +++ b/backend/api/v1/modules/crm/quotes/routes.py @@ -1,22 +1,76 @@ -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, File, Query, UploadFile, status from sqlalchemy.orm import Session from core.database import get_core_db from core.security import get_current_user -from . import service +from . import pdf_service, service from .dto import ( QuoteCreate, QuoteItemCreate, QuoteItemResponse, QuoteItemUpdate, QuoteResponse, + QuoteSettingsInput, + QuoteSettingsResponse, QuoteUpdate, + SendQuoteEmailRequest, ) router = APIRouter() +# ----- Configuración de marca del formato de cotización ----- + +@router.get("/quote-settings", response_model=QuoteSettingsResponse) +def get_quote_settings( + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id) + return obj or QuoteSettingsResponse() + + +@router.put("/quote-settings", response_model=QuoteSettingsResponse) +def save_quote_settings( + payload: QuoteSettingsInput, + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return pdf_service.upsert_settings(db, current_user["tenant_id"], company_id, payload.model_dump(exclude_unset=True)) + + +@router.post("/quote-settings/logo", response_model=QuoteSettingsResponse) +async def upload_quote_logo( + company_id: int = Query(...), + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + from core.storage_s3 import put_object_bytes + tenant_id = current_user["tenant_id"] + content = await file.read() + safe = (file.filename or "logo").replace("/", "-") + key = f"tenants/{tenant_id}/companies/{company_id}/crm-quote-logo/{safe}" + put_object_bytes(key, content, content_type=file.content_type or "image/png") + return pdf_service.set_logo_key(db, tenant_id, company_id, key) + + +@router.get("/quote-settings/logo-url") +def get_logo_url( + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + from core.storage_s3 import presigned_get_url + obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id) + if not obj or not obj.logo_file_key: + return {"url": None} + return {"url": presigned_get_url(obj.logo_file_key)} + + def _user_id(current_user: dict) -> str | None: return current_user.get("sub") or current_user.get("id") @@ -98,6 +152,32 @@ def reject_quote( return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id) +@router.get("/quotes/{quote_id}/pdf-url") +def quote_pdf_url( + quote_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Genera el PDF de la cotización (formato maestro + marca) y devuelve una URL.""" + url = pdf_service.get_pdf_url(db, quote_id, current_user["tenant_id"], company_id) + return {"url": url} + + +@router.post("/quotes/{quote_id}/send-email") +async def quote_send_email( + quote_id: int, + payload: SendQuoteEmailRequest, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Genera el PDF y lo envía por correo (al cliente o al destinatario indicado).""" + return await pdf_service.send_quote_email( + db, quote_id, current_user["tenant_id"], company_id, payload.to, payload.subject, payload.message + ) + + @router.post("/quotes/{quote_id}/clone", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED) def clone_quote( quote_id: int, From e09cb02b8b3cfcbf077ada579e5af743bdabca8b Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Wed, 29 Jul 2026 13:22:24 -0600 Subject: [PATCH 29/40] =?UTF-8?q?feat(crm):=20cotizaci=C3=B3n=20=E2=80=94?= =?UTF-8?q?=20Ver=20PDF,=20Enviar=20por=20correo=20y=20pantalla=20de=20mar?= =?UTF-8?q?ca=20por=20tenant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cliente API: quotesAPI.pdfUrl/sendEmail + quoteSettingsAPI (get/save/uploadLogo). - Cotización: botones Ver PDF y Enviar por correo (modal con destinatario/asunto/mensaje). - Configuración → Formato de cotización: emisor, logo, color, prefijo y textos por defecto (por compañía). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/api/crm/commercial.ts | 27 ++++- .../src/lib/components/sidebar/modules.ts | 4 + .../crm/cotizaciones/[id]/+page.svelte | 65 ++++++++++- .../settings/cotizacion/+page.svelte | 104 ++++++++++++++++++ 4 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/dashboard/settings/cotizacion/+page.svelte diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index a131db6..37bd255 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -157,7 +157,32 @@ export const quotesAPI = { reject: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})), clone: (id: number, companyId: number) => unwrap(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)), - items: (quoteId: number, companyId: number) => unwrap(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)) + items: (quoteId: number, companyId: number) => unwrap(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)), + pdfUrl: (id: number, companyId: number) => unwrap<{ url: string }>(api.get(`/v1/crm/quotes/${id}/pdf-url?${qp(companyId)}`)), + sendEmail: (id: number, companyId: number, body: { to?: string | null; subject?: string | null; message?: string | null }) => + unwrap<{ sent_to: string; reference: string }>(api.post(`/v1/crm/quotes/${id}/send-email?${qp(companyId)}`, body)) +}; + +// ---------- Configuración de marca del formato de cotización ---------- +export interface QuoteSettings { + id?: number | null; + emitter_name?: string | null; emitter_rfc?: string | null; emitter_address?: string | null; + emitter_phone?: string | null; emitter_email?: string | null; emitter_website?: string | null; + logo_file_key?: string | null; accent_color?: string | null; quote_prefix?: string | null; + default_terms?: string | null; footer_note?: string | null; +} + +export const quoteSettingsAPI = { + get: (companyId: number) => unwrap(api.get(`/v1/crm/quote-settings?${qp(companyId)}`)), + save: (companyId: number, data: QuoteSettings) => unwrap(api.put(`/v1/crm/quote-settings?${qp(companyId)}`, data)), + logoUrl: (companyId: number) => unwrap<{ url: string | null }>(api.get(`/v1/crm/quote-settings/logo-url?${qp(companyId)}`)), + async uploadLogo(companyId: number, file: File): Promise { + const fd = new FormData(); + fd.append('file', file); + const res = await (api as any).request(`/v1/crm/quote-settings/logo?${qp(companyId)}`, { method: 'POST', body: fd }); + if (res.error) throw new Error(res.error); + return res.data as QuoteSettings; + } }; // ---------- Catálogos de referencia (Incoterms, participantes) ---------- diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 8f85a92..8d91390 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -92,6 +92,10 @@ export function getNavMain(): NavMainItem[] { title: 'Configuración', url: '/dashboard/settings/general', icon: Settings2, + items: [ + { title: 'General', url: '/dashboard/settings/general' }, + { title: 'Formato de cotización', url: '/dashboard/settings/cotizacion' }, + ], }, ]; } diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index 0f39db5..585d5c6 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -1,5 +1,5 @@ + +
+
+

Formato de cotización

+

Marca y encabezados que se imprimen en el PDF de las cotizaciones (por compañía).

+
+ + {#if !companyId} + Selecciona una compañía activa. + {:else} + + Logo + +
+ {#if logoUrl} + Logo + {:else} +
Sin logo
+ {/if} + +
+
+
+ + + Datos del emisor + Aparecen en el encabezado del PDF. + + +
+ + + + + + + + +
+
+
+ + + Textos por defecto + +
+ + +
+
+
+ +
+ {/if} +
From 03055cd37730d722d5139bd98659c63b0e2bcdb3 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 30 Jul 2026 10:33:14 -0600 Subject: [PATCH 30/40] =?UTF-8?q?fix(crm):=20servir=20el=20PDF=20de=20coti?= =?UTF-8?q?zaci=C3=B3n=20por=20el=20backend=20(no=20exponer=20MinIO)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La URL prefirmada usaba el host interno http://minio:9000 (no accesible desde el navegador). Se agrega GET /quotes/{id}/pdf que devuelve el PDF por el backend (vía nginx) y el visor usa un blob autenticado (api.getBlob). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/crm/quotes/routes.py | 19 +++++++++++++------ frontend/src/lib/api/crm/commercial.ts | 2 +- .../crm/cotizaciones/[id]/+page.svelte | 6 ++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/api/v1/modules/crm/quotes/routes.py b/backend/api/v1/modules/crm/quotes/routes.py index a5b38d5..7693f01 100644 --- a/backend/api/v1/modules/crm/quotes/routes.py +++ b/backend/api/v1/modules/crm/quotes/routes.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, File, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status from sqlalchemy.orm import Session from core.database import get_core_db @@ -152,16 +152,23 @@ def reject_quote( return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id) -@router.get("/quotes/{quote_id}/pdf-url") -def quote_pdf_url( +@router.get("/quotes/{quote_id}/pdf") +def quote_pdf( quote_id: int, company_id: int = Query(..., description="Company ID"), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db), ): - """Genera el PDF de la cotización (formato maestro + marca) y devuelve una URL.""" - url = pdf_service.get_pdf_url(db, quote_id, current_user["tenant_id"], company_id) - return {"url": url} + """Devuelve el PDF de la cotización directamente (vía backend, sin exponer MinIO).""" + tenant_id = current_user["tenant_id"] + quote = service.get_quote(db, quote_id, tenant_id, company_id) + pdf_bytes = pdf_service.build_pdf_bytes(db, quote, tenant_id, company_id) + ref = (quote.reference or f"cot-{quote.id}").replace("/", "-") + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="cotizacion-{ref}.pdf"'}, + ) @router.post("/quotes/{quote_id}/send-email") diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index 37bd255..e190fde 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -158,7 +158,7 @@ export const quotesAPI = { clone: (id: number, companyId: number) => unwrap(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)), items: (quoteId: number, companyId: number) => unwrap(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)), - pdfUrl: (id: number, companyId: number) => unwrap<{ url: string }>(api.get(`/v1/crm/quotes/${id}/pdf-url?${qp(companyId)}`)), + pdfBlob: (id: number, companyId: number) => (api as any).getBlob(`/v1/crm/quotes/${id}/pdf?${qp(companyId)}`) as Promise, sendEmail: (id: number, companyId: number, body: { to?: string | null; subject?: string | null; message?: string | null }) => unwrap<{ sent_to: string; reference: string }>(api.post(`/v1/crm/quotes/${id}/send-email?${qp(companyId)}`, body)) }; diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index 585d5c6..6573670 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -146,8 +146,10 @@ if (!companyId || !quote) return; busy = true; try { - const { url } = await quotesAPI.pdfUrl(quote.id, companyId); - if (url) window.open(url, '_blank', 'noopener'); + const blob = await quotesAPI.pdfBlob(quote.id, companyId); + const url = URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60000); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo generar el PDF'); } finally { From e269e46d8875346c73af7de15ae5eb8a9391076f Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 30 Jul 2026 10:49:23 -0600 Subject: [PATCH 31/40] =?UTF-8?q?fix(crm):=20env=C3=ADo=20de=20cotizaci?= =?UTF-8?q?=C3=B3n=20por=20correo=20con=20aiosmtplib.send=20(sin=20doble?= =?UTF-8?q?=20STARTTLS)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/v1/modules/crm/quotes/pdf_service.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/backend/api/v1/modules/crm/quotes/pdf_service.py b/backend/api/v1/modules/crm/quotes/pdf_service.py index 2225090..4fc9026 100644 --- a/backend/api/v1/modules/crm/quotes/pdf_service.py +++ b/backend/api/v1/modules/crm/quotes/pdf_service.py @@ -210,19 +210,25 @@ async def send_quote_email( part.add_header("Content-Disposition", f'attachment; filename="cotizacion-{ref}.pdf"') msg.attach(part) + if not (cfg.SMTP_USER and cfg.SMTP_PASSWORD): + raise HTTPException(status_code=503, detail="El correo saliente (SMTP) no está configurado en el servidor.") ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: - if cfg.SMTP_PORT == 465: - async with aiosmtplib.SMTP(hostname=cfg.SMTP_HOST, port=cfg.SMTP_PORT, use_tls=True, tls_context=ctx) as smtp: - await smtp.login(cfg.SMTP_USER, cfg.SMTP_PASSWORD) - await smtp.send_message(msg) - else: - async with aiosmtplib.SMTP(hostname=cfg.SMTP_HOST, port=cfg.SMTP_PORT, tls_context=ctx) as smtp: - await smtp.starttls(tls_context=ctx) - await smtp.login(cfg.SMTP_USER, cfg.SMTP_PASSWORD) - await smtp.send_message(msg) + # Puerto 465 = SSL implícito; los demás (587/2525/…) = STARTTLS. + await aiosmtplib.send( + msg, + hostname=cfg.SMTP_HOST, + port=cfg.SMTP_PORT, + username=cfg.SMTP_USER, + password=cfg.SMTP_PASSWORD, + use_tls=(cfg.SMTP_PORT == 465), + start_tls=(cfg.SMTP_PORT != 465), + tls_context=ctx, + validate_certs=False, + timeout=30, + ) except Exception as exc: logger.error("Error enviando cotización %s: %s", quote_id, exc) raise HTTPException(status_code=502, detail=f"No se pudo enviar el correo: {exc}") From 87d23b3d23d042bf58a53be8a42649e1103a9f6e Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Thu, 30 Jul 2026 11:06:16 -0600 Subject: [PATCH 32/40] =?UTF-8?q?feat(crm):=20redise=C3=B1o=20profesional?= =?UTF-8?q?=20del=20PDF=20de=20cotizaci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banda de encabezado, logo + emisor, título con regla, panel de datos, barras de sección en color, tabla de costos con encabezado y filas alternadas, caja de totales, y pie con banda. Fix: elipsis "…" -> "..." (evita "?" en latin-1); oculta secciones sin datos; color por defecto navy. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/crm/quotes/pdf.py | 404 +++++++++++------- .../api/v1/modules/crm/quotes/pdf_service.py | 2 +- 2 files changed, 250 insertions(+), 156 deletions(-) diff --git a/backend/api/v1/modules/crm/quotes/pdf.py b/backend/api/v1/modules/crm/quotes/pdf.py index 7caebfc..9887673 100644 --- a/backend/api/v1/modules/crm/quotes/pdf.py +++ b/backend/api/v1/modules/crm/quotes/pdf.py @@ -1,9 +1,8 @@ -"""Generador del PDF de Cotización (formato maestro) sin dependencias de sistema. +"""Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema. -Compone un PDF 1.4 válido byte a byte (fuente Helvetica) e incrusta el logo como -imagen JPEG (XObject /DCTDecode) usando Pillow para normalizarlo. El branding del -emisor (nombre, RFC, dirección, contacto, color) viene de la configuración por -tenant. +Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección, +tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado +(JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant. """ from __future__ import annotations @@ -11,9 +10,10 @@ from __future__ import annotations import io from decimal import Decimal -_PAGE_W = 612 -_PAGE_H = 792 -_MARGIN = 50 +_W = 612 +_H = 792 +_ML = 50 # margen izquierdo +_MR = 562 # margen derecho (x) CONCEPT_LABELS = { "flete_internacional": "Flete internacional", @@ -23,44 +23,63 @@ CONCEPT_LABELS = { "otros": "Otros cargos", } - -def _esc(text: str) -> str: - out = (str(text) if text is not None else "").encode("latin-1", "replace").decode("latin-1") - return out.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") +_TRANSLATE = str.maketrans({"—": "-", "–": "-", "“": '"', "”": '"', "‘": "'", "’": "'", "•": "-", "…": "...", "\t": " "}) -def _money(value, currency: str = "") -> str: - d = Decimal(str(value or 0)).quantize(Decimal("0.01")) - return (f"{currency} " if currency else "") + f"{d:,.2f}" +def _esc(text) -> str: + s = ("" if text is None else str(text)).translate(_TRANSLATE) + s = s.encode("latin-1", "replace").decode("latin-1") + return s.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)") -def _wrap(text: str, width: int) -> list[str]: +def _money(value) -> str: + return f"{Decimal(str(value or 0)).quantize(Decimal('0.01')):,.2f}" + + +def _num(value) -> str: + return f"{Decimal(str(value or 0)):,.2f}" + + +# Ancho aprox de una cadena en Helvetica (para alinear a la derecha / truncar) +def _text_w(s: str, size: float, bold: bool = False) -> float: + return len(s) * size * (0.56 if bold else 0.52) + + +def _fit(s: str, size: float, max_w: float) -> str: + s = s or "" + if _text_w(s, size) <= max_w: + return s + while s and _text_w(s + "…", size) > max_w: + s = s[:-1] + return s + "…" + + +def _wrap(text: str, width_chars: int) -> list[str]: words = (text or "").split() if not words: return [] - lines, cur = [], "" + out, cur = [], "" for w in words: cand = f"{cur} {w}".strip() - if len(cand) > width and cur: - lines.append(cur) + if len(cand) > width_chars and cur: + out.append(cur) cur = w else: cur = cand if cur: - lines.append(cur) - return lines + out.append(cur) + return out def _hex_rgb(hexs: str | None) -> tuple[float, float, float]: try: - h = (hexs or "#2f6bf0").lstrip("#") + h = (hexs or "#12294c").lstrip("#") return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value] except Exception: - return (0.184, 0.42, 0.94) + return (0.07, 0.16, 0.30) def _prep_logo(logo_bytes: bytes | None): - """Normaliza el logo a JPEG RGB. Devuelve (jpeg_bytes, w, h) o None.""" if not logo_bytes: return None try: @@ -69,18 +88,44 @@ def _prep_logo(logo_bytes: bytes | None): im = Image.open(io.BytesIO(logo_bytes)).convert("RGB") im.thumbnail((600, 300)) buf = io.BytesIO() - im.save(buf, format="JPEG", quality=85) + im.save(buf, format="JPEG", quality=88) return buf.getvalue(), im.width, im.height except Exception: return None -def _kv_lines(pairs: list[tuple[str, str]]) -> list[tuple[str, int]]: - out: list[tuple[str, int]] = [] - for k, v in pairs: - if v not in (None, "", "None"): - out.append((f"{k}: {v}", 10)) - return out +class _Canvas: + """Acumula operadores de contenido con paginación simple.""" + + def __init__(self): + self.pages: list[list[str]] = [[]] + self.y = _H + + @property + def ops(self) -> list[str]: + return self.pages[-1] + + def new_page(self): + self.pages.append([]) + self.y = _H - 50 + + def ensure(self, needed: float): + if self.y - needed < 50: + self.new_page() + + def rect(self, x, y, w, h, rgb): + r, g, b = rgb + self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg {x:.1f} {y:.1f} {w:.1f} {h:.1f} re f") + + def line(self, x1, y1, x2, y2, rgb, width=0.6): + r, g, b = rgb + self.ops.append(f"{width} w {r:.3f} {g:.3f} {b:.3f} RG {x1:.1f} {y1:.1f} m {x2:.1f} {y2:.1f} l S") + + def text(self, x, y, s, size=10, rgb=(0, 0, 0), bold=False, right=False): + font = "F2" if bold else "F1" + r, g, b = rgb + tx = x - _text_w(str(s), size, bold) if right else x + self.ops.append(f"BT /{font} {size} Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {tx:.1f} {y:.1f} Tm ({_esc(s)}) Tj ET") def build_quote_pdf( @@ -96,150 +141,198 @@ def build_quote_pdf( terms: str | None, footer: str | None, logo_bytes: bytes | None = None, - accent: str | None = "#2f6bf0", + accent: str | None = "#12294c", ) -> bytes: - accent_rgb = _hex_rgb(accent) + ACC = _hex_rgb(accent) + INK = (0.10, 0.15, 0.24) + GRAY = (0.42, 0.47, 0.55) + LINE = (0.80, 0.84, 0.90) + ZEBRA = (0.955, 0.965, 0.980) logo = _prep_logo(logo_bytes) - # ---- Cuerpo (debajo del encabezado) ---- - body: list[tuple[str, int]] = [] + c = _Canvas() + # ---------------- Encabezado ---------------- + c.rect(0, _H - 12, _W, 12, ACC) # banda superior + logo_bottom = _H - 95 + if logo: + _, lw, lh = logo + dw, dh = 150.0, 150.0 * lh / lw + if dh > 55: + dh, dw = 55.0, 55.0 * lw / lh + c.ops.append(f"q {dw:.1f} 0 0 {dh:.1f} {_ML} {logo_bottom:.1f} cm /Im0 Do Q") + else: + c.text(_ML, _H - 55, emitter.get("name") or "Emisor", 16, INK, bold=True) + + # Emisor (derecha) + ex, ey = 320, _H - 42 + c.text(ex, ey, emitter.get("name") or "Emisor", 12, INK, bold=True) + ey -= 14 + em_lines = [] + if emitter.get("rfc"): + em_lines.append(f"RFC: {emitter['rfc']}") + for a in (emitter.get("address") or "").splitlines(): + if a.strip(): + em_lines.append(a.strip()) + contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x]) + if contact: + em_lines.append(contact) + for ln in em_lines[:5]: + c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY) + ey -= 11 + + # Título + regla + c.text(_ML, _H - 150, "COTIZACIÓN", 26, INK, bold=True) + c.line(_ML, _H - 158, _ML + 190, _H - 158, ACC, 2) + + # Panel de datos (derecha) + px, pw = 320, _MR - 320 + py_top = _H - 128 + ph = 74 + c.rect(px, py_top - ph, pw, ph, ZEBRA) + c.line(px, py_top, px, py_top - ph, LINE) + hy = py_top - 15 + info = [ + ("No.", head.get("reference") or "-"), + ("Fecha", head.get("issue_date") or "-"), + ("Vigencia", head.get("valid_until") or "-"), + ("Ejecutivo", head.get("owner") or "-"), + ("Estatus", str(head.get("status") or "-").capitalize()), + ] + for k, v in info: + c.text(px + 10, hy, f"{k}:", 8.5, GRAY, bold=True) + c.text(px + 66, hy, _fit(str(v), 9, pw - 76), 9, INK) + hy -= 12.5 + + c.y = _H - 215 + + # ---------------- Helpers de sección ---------------- def section(title: str): - body.append(("", 6)) - body.append((title.upper(), 11)) - body.append(("_" * 92, 8)) + c.ensure(30) + c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC) + c.text(_ML + 8, c.y - 13, title.upper(), 9.5, (1, 1, 1), bold=True) + c.y -= 26 - # Cliente + def kv_block(pairs: list[tuple[str, str]]): + rows = [(k, v) for k, v in pairs if v not in (None, "", "None")] + if not rows: + return False + col_w = (_MR - _ML) / 2 + i = 0 + while i < len(rows): + c.ensure(16) + for col in range(2): + if i + col < len(rows): + k, v = rows[i + col] + x = _ML + 6 + col * col_w + c.text(x, c.y - 11, f"{k}:", 9, GRAY, bold=True) + c.text(x + _text_w(f"{k}: ", 9, True), c.y - 11, _fit(str(v), 9, col_w - 90), 9, INK) + c.y -= 16 + i += 2 + c.y -= 4 + return True + + # ---------------- Cliente ---------------- section("Cliente") - for line in _kv_lines([ + if not kv_block([ ("Cliente", client.get("name")), ("RFC", client.get("rfc")), ("Correo", client.get("email")), ("Teléfono", client.get("phone")), ]): - body.append(line) + c.text(_ML + 6, c.y - 11, "—", 9, GRAY) + c.y -= 16 - # Carga / Ruta - if cargo: + # ---------------- Carga / Ruta (solo si hay datos) ---------------- + if [v for _, v in cargo if v not in (None, "", "None")]: section("Información de la carga") - for line in _kv_lines(cargo): - body.append(line) - if route: + kv_block(cargo) + if [v for _, v in route if v not in (None, "", "None")]: section("Ruta logística") - for line in _kv_lines(route): - body.append(line) + kv_block(route) - # Costos + # ---------------- Costos ---------------- section("Costos cotizados") - body.append(("Concepto Cant. Tarifa Importe", 9)) - body.append(("-" * 92, 8)) + x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6 + row_h = 18 + # encabezado de tabla + c.ensure(row_h) + c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ACC) + c.text(x_con + 6, c.y - 13, "Concepto", 9, (1, 1, 1), bold=True) + c.text(x_cant, c.y - 13, "Cant.", 9, (1, 1, 1), bold=True, right=True) + c.text(x_tar, c.y - 13, "Tarifa", 9, (1, 1, 1), bold=True, right=True) + c.text(x_imp, c.y - 13, "Importe", 9, (1, 1, 1), bold=True, right=True) + c.y -= row_h + z = False for it in items: code = str(it.get("concept") or "") label = CONCEPT_LABELS.get(code, code) desc = str(it.get("description") or "") if desc: - label = f"{label} — {desc}" + label = f"{label} - {desc}" qty = Decimal(str(it.get("quantity") or 0)) unit = Decimal(str(it.get("unit_sale") or 0)) amount = (qty * unit).quantize(Decimal("0.01")) - row = f"{label[:40].ljust(40)} {qty:>6.2f} {unit:>14,.2f} {amount:>14,.2f}" - body.append((row, 9)) - body.append(("-" * 92, 8)) - body.append((f"Subtotal {currency}: {_money(subtotal)}", 11)) - body.append(("IVA: según aplique", 9)) - body.append((f"Total {currency}: {_money(subtotal)} + IVA", 12)) + c.ensure(row_h) + if z: + c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA) + c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK) + c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True) + c.text(x_tar, c.y - 13, _money(unit), 9, INK, right=True) + c.text(x_imp, c.y - 13, _money(amount), 9, INK, right=True) + c.y -= row_h + z = not z + if not items: + c.text(_ML + 6, c.y - 13, "Sin conceptos.", 9, GRAY) + c.y -= row_h + # borde de la tabla + c.line(_ML, c.y, _MR, c.y, LINE) + c.y -= 12 - # Condiciones + # ---------------- Totales (caja derecha) ---------------- + tb_x, tb_w = 360, _MR - 360 + c.ensure(58) + c.rect(tb_x, c.y - 58, tb_w, 58, ZEBRA) + c.line(tb_x, c.y, tb_x, c.y - 58, LINE) + ty = c.y - 16 + c.text(tb_x + 10, ty, "Subtotal", 9.5, GRAY, bold=True) + c.text(_MR - 8, ty, f"{currency} {_money(subtotal)}", 9.5, INK, right=True) + ty -= 15 + c.text(tb_x + 10, ty, "IVA", 9.5, GRAY, bold=True) + c.text(_MR - 8, ty, "según aplique", 9, GRAY, right=True) + ty -= 6 + c.rect(tb_x, ty - 20, tb_w, 20, ACC) + c.text(tb_x + 10, ty - 14, "TOTAL", 10, (1, 1, 1), bold=True) + c.text(_MR - 8, ty - 14, f"{currency} {_money(subtotal)} + IVA", 10, (1, 1, 1), bold=True, right=True) + c.y -= 70 + + # ---------------- Condiciones ---------------- if terms: section("Condiciones comerciales") for para in terms.splitlines(): - for line in _wrap(para, 105) or [""]: - body.append((line, 9)) + for ln in (_wrap(para, 108) or [""]): + c.ensure(13) + c.text(_ML + 6, c.y - 10, ln, 8.8, GRAY) + c.y -= 12 + c.y -= 4 - # ---- Paginación (página 1 con encabezado; siguientes solo cuerpo) ---- - p1_top = _PAGE_H - 150 # y donde inicia el cuerpo en la página 1 - pN_top = _PAGE_H - _MARGIN - line_h = 14 - pages: list[list[tuple[float, tuple[str, int]]]] = [] - cur: list[tuple[float, tuple[str, int]]] = [] - y = p1_top - for item in body: - if y < _MARGIN + 40: - pages.append(cur) - cur = [] - y = pN_top - cur.append((y, item)) - y -= line_h - pages.append(cur) - - # ---- Content streams ---- - streams: list[bytes] = [] - for pi, page in enumerate(pages): - parts: list[str] = [] - if pi == 0: - # barra de acento arriba - r, g, b = accent_rgb - parts.append(f"{r:.3f} {g:.3f} {b:.3f} rg") - parts.append(f"0 {_PAGE_H - 8} {_PAGE_W} 8 re f") - # logo - logo_y = _PAGE_H - 30 - if logo: - _, lw, lh = logo - dw = 150.0 - dh = dw * lh / lw - if dh > 60: - dh = 60.0 - dw = dh * lw / lh - parts.append(f"q {dw:.2f} 0 0 {dh:.2f} {_MARGIN} {logo_y - dh:.2f} cm /Im0 Do Q") - # emisor (columna derecha) - ex = 330 - ey = logo_y - 6 - parts.append("BT /F1 12 Tf 0.09 0.14 0.24 rg") - parts.append(f"1 0 0 1 {ex} {ey} Tm ({_esc(emitter.get('name') or 'Emisor')}) Tj") - parts.append("/F1 9 Tf 0.35 0.41 0.5 rg 13 TL") - em_lines = [] - if emitter.get("rfc"): - em_lines.append(f"RFC: {emitter['rfc']}") - for a in (emitter.get("address") or "").splitlines(): - if a.strip(): - em_lines.append(a.strip()) - contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x]) - if contact: - em_lines.append(contact) - for ln in em_lines[:5]: - parts.append(f"T* ({_esc(ln)}) Tj") - parts.append("ET") - # título - parts.append("BT /F1 22 Tf 0.09 0.14 0.24 rg") - parts.append(f"1 0 0 1 {_MARGIN} {_PAGE_H - 120} Tm (COTIZACION) Tj ET") - # datos de cabecera (derecha del título) - parts.append("BT /F1 9 Tf 0.2 0.25 0.35 rg 12 TL") - parts.append(f"1 0 0 1 330 {_PAGE_H - 100} Tm ({_esc('No.: ' + str(head.get('reference') or '-'))}) Tj") - for hl in [ - f"Fecha: {head.get('issue_date') or '-'}", - f"Vigencia: {head.get('valid_until') or '-'}", - f"Ejecutivo: {head.get('owner') or '-'} Estatus: {head.get('status') or '-'}", - ]: - parts.append(f"T* ({_esc(hl)}) Tj") - parts.append("ET") - # cuerpo - for yy, (text, size) in page: - parts.append(f"BT /F1 {size} Tf 0 0 0 rg 1 0 0 1 {_MARGIN} {yy:.2f} Tm ({_esc(text)}) Tj ET") - # pie + # pie en todas las páginas + for ops in c.pages: if footer: - parts.append(f"BT /F1 8 Tf 0.5 0.5 0.5 rg 1 0 0 1 {_MARGIN} {_MARGIN - 20} Tm ({_esc(footer[:110])}) Tj ET") - streams.append("\n".join(parts).encode("latin-1", "replace")) + r, g, b = GRAY + ops.append(f"BT /F1 8 Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {_ML} 34 Tm ({_esc(_fit(footer, 8, _MR - _ML))}) Tj ET") + ops.append(f"{ACC[0]:.3f} {ACC[1]:.3f} {ACC[2]:.3f} rg 0 0 {_W} 6 re f") - # ---- Ensamblado de objetos ---- + # ---------------- Ensamblado ---------------- + streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages] objects: list[bytes] = [] - def add(obj: bytes) -> int: + def add(obj: bytes): objects.append(obj) - return len(objects) - n_pages = len(pages) + n_pages = len(c.pages) has_img = 1 if logo else 0 - font_num = 3 - img_num = 4 if has_img else None - base = 5 if has_img else 4 + # numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams + img_num = 5 if has_img else None + base = 6 if has_img else 5 page_nums = list(range(base, base + n_pages)) content_nums = list(range(base + n_pages, base + 2 * n_pages)) @@ -247,35 +340,36 @@ def build_quote_pdf( add(b"<< /Type /Catalog /Pages 2 0 R >>") add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1")) add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>") + add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") if logo: jpeg, lw, lh = logo - img_obj = ( - f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} " - f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n" - ).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream" - add(img_obj) + add( + ( + f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} " + f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n" + ).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream" + ) for i in range(n_pages): - res = f"/Font << /F1 {font_num} 0 R >>" + res = "/Font << /F1 3 0 R /F2 4 0 R >>" if has_img and i == 0: res += f" /XObject << /Im0 {img_num} 0 R >>" - page_dict = ( - f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] " - f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>" + add( + ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_W} {_H}] " + f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>" + ).encode("latin-1") ) - add(page_dict.encode("latin-1")) for stream in streams: add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream") - out = bytearray() - out += b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n" - offsets: list[int] = [] + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [] for i, obj in enumerate(objects, start=1): offsets.append(len(out)) out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n" xref_pos = len(out) total = len(objects) + 1 - out += f"xref\n0 {total}\n".encode("latin-1") - out += b"0000000000 65535 f \n" + out += f"xref\n0 {total}\n".encode("latin-1") + b"0000000000 65535 f \n" for off in offsets: out += f"{off:010d} 00000 n \n".encode("latin-1") out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1") diff --git a/backend/api/v1/modules/crm/quotes/pdf_service.py b/backend/api/v1/modules/crm/quotes/pdf_service.py index 4fc9026..4ed0678 100644 --- a/backend/api/v1/modules/crm/quotes/pdf_service.py +++ b/backend/api/v1/modules/crm/quotes/pdf_service.py @@ -98,7 +98,7 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) "email": settings.emitter_email if settings else None, "website": settings.emitter_website if settings else None, } - accent = (settings.accent_color if settings else None) or "#2f6bf0" + accent = (settings.accent_color if settings else None) or "#12294c" prefix = (settings.quote_prefix if settings else None) or "COT" terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS footer = settings.footer_note if settings else None From 915bdd19fe051276f68860ad2b91179e5269da8b Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Mon, 3 Aug 2026 17:51:40 -0600 Subject: [PATCH 33/40] feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solicitud de servicio: - Campos del documento maestro de cotización (ruta estructurada por país, mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas). - Origen/Destino seleccionables por catálogo de país (seed ya poblado). - Validación de contacto asociado (422 si no existe). Ciclo Oportunidad -> Solicitud -> Cotización -> Operación: - Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo. - Conversión Oportunidad->Solicitud idempotente con back-link. - Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL). - Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos. - Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF. Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O), Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila). Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad, tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento). Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG). 25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones, embarques); suite completa en verde (101 pruebas). Co-Authored-By: Claude Opus 4.8 --- ...c2d3e4f5a6_service_request_quote_fields.py | 158 ++++++++++++++++++ .../api/v1/modules/crm/catalogs/seed_data.py | 82 +++++++++ backend/api/v1/modules/crm/common/__init__.py | 0 backend/api/v1/modules/crm/common/folios.py | 92 ++++++++++ .../api/v1/modules/crm/documents/models.py | 4 + .../api/v1/modules/crm/opportunities/dto.py | 5 + .../v1/modules/crm/opportunities/models.py | 7 + .../v1/modules/crm/opportunities/service.py | 4 + backend/api/v1/modules/crm/quotes/dto.py | 2 + backend/api/v1/modules/crm/quotes/models.py | 2 + .../api/v1/modules/crm/quotes/pdf_service.py | 11 +- backend/api/v1/modules/crm/quotes/routes.py | 18 ++ backend/api/v1/modules/crm/quotes/service.py | 91 +++++++++- .../v1/modules/crm/service_requests/dto.py | 92 +++++++++- .../v1/modules/crm/service_requests/models.py | 51 +++++- .../modules/crm/service_requests/service.py | 27 ++- .../api/v1/modules/ops/shipments/routes.py | 5 +- .../api/v1/modules/ops/shipments/service.py | 33 +++- backend/tests/conftest.py | 2 + backend/tests/test_catalogs_seed.py | 49 ++++++ backend/tests/test_folios.py | 43 +++++ backend/tests/test_quotes.py | 73 +++++++- backend/tests/test_service_requests.py | 90 ++++++++++ backend/tests/test_shipments.py | 44 +++++ frontend/src/lib/api/crm/commercial.ts | 42 ++++- frontend/src/lib/api/crm/types.ts | 3 + frontend/src/lib/api/ops/index.ts | 4 +- .../crm/ServiceRequestFields.svelte | 155 +++++++++++++++++ frontend/src/lib/components/crm/format.ts | 20 ++- .../crm/cotizaciones/[id]/+page.svelte | 44 ++++- .../crm/cotizaciones/nuevo/+page.svelte | 3 +- .../dashboard/crm/oportunidades/+page.svelte | 83 ++++++++- .../crm/solicitudes/[id]/+page.svelte | 77 +++++---- .../crm/solicitudes/nuevo/+page.svelte | 48 ++---- 34 files changed, 1364 insertions(+), 100 deletions(-) create mode 100644 backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py create mode 100644 backend/api/v1/modules/crm/common/__init__.py create mode 100644 backend/api/v1/modules/crm/common/folios.py create mode 100644 backend/tests/test_catalogs_seed.py create mode 100644 backend/tests/test_folios.py create mode 100644 frontend/src/lib/components/crm/ServiceRequestFields.svelte diff --git a/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py b/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py new file mode 100644 index 0000000..02a030a --- /dev/null +++ b/backend/alembic/versions/b1c2d3e4f5a6_service_request_quote_fields.py @@ -0,0 +1,158 @@ +"""Campos del documento maestro de cotización en la solicitud + folios del ciclo comercial + +Revision ID: b1c2d3e4f5a6 +Revises: a0b1c2d3e4f5 +Create Date: 2026-08-03 00:00:00.000000 + +Amplía crm.service_requests con los campos que exige el documento maestro de +cotización, agrega los back-links y la dirección impo/expo del ciclo +Oportunidad→Solicitud→Cotización→Operación, y crea crm.folio_counters para los +folios auto-generados ({LETRA}{AAAA}-{MM}-{NNN}-{DIR}). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b1c2d3e4f5a6" +down_revision: Union[str, None] = "a0b1c2d3e4f5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + +# Columnas nuevas de crm.service_requests (nombre, tipo, kwargs). +_SR_COLUMNS = [ + ("contact_id", sa.Integer(), {}), + ("request_date", sa.Date(), {}), + ("currency", sa.String(length=3), {}), + ("priority", sa.String(length=20), {}), + ("origin_country", sa.String(length=3), {}), + ("origin_city", sa.String(length=120), {}), + ("origin_port", sa.String(length=20), {}), + ("destination_country", sa.String(length=3), {}), + ("destination_city", sa.String(length=120), {}), + ("destination_port", sa.String(length=20), {}), + ("pickup_location", sa.String(length=255), {}), + ("delivery_location", sa.String(length=255), {}), + ("estimated_shipment_date", sa.Date(), {}), + ("cargo_value", sa.Numeric(14, 2), {}), + ("insurance_required", sa.Boolean(), {"server_default": sa.text("false")}), + ("hs_code", sa.String(length=20), {}), + ("goods_origin_country", sa.String(length=3), {}), + ("hazardous_imo", sa.Boolean(), {"server_default": sa.text("false")}), + ("refrigerated", sa.Boolean(), {"server_default": sa.text("false")}), + ("stackable", sa.Boolean(), {"server_default": sa.text("false")}), + ("pieces_count", sa.Integer(), {}), + ("boxes_count", sa.Integer(), {}), + ("pallets_count", sa.Integer(), {}), + ("net_weight", sa.Numeric(14, 3), {}), + ("length_cm", sa.Numeric(10, 2), {}), + ("width_cm", sa.Numeric(10, 2), {}), + ("height_cm", sa.Numeric(10, 2), {}), + ("measurement_unit", sa.String(length=20), {}), + ("container_count", sa.Integer(), {}), + ("packaging_type", sa.String(length=20), {}), + ("oversized", sa.Boolean(), {"server_default": sa.text("false")}), + ("weight_per_pallet", sa.Numeric(14, 3), {}), + ("volume_per_pallet", sa.Numeric(14, 3), {}), + ("additional_services", sa.JSON(), {}), + ("payment_method", sa.String(length=20), {}), + ("client_notes", sa.Text(), {}), + ("internal_notes", sa.Text(), {}), +] + + +def upgrade() -> None: + # ----- crm.service_requests: campos del documento maestro de cotización ----- + for name, col_type, kwargs in _SR_COLUMNS: + nullable = "server_default" not in kwargs # los boolean quedan NOT NULL con default false + op.add_column( + "service_requests", + sa.Column(name, col_type, nullable=nullable, **kwargs), + schema=SCHEMA, + ) + op.create_foreign_key( + "fk_crm_service_requests_contact_id", "service_requests", "contacts", + ["contact_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA, + ) + op.create_index( + "ix_crm_service_requests_contact_id", "service_requests", ["contact_id"], schema=SCHEMA + ) + + # ----- crm.documents: adjuntos de una solicitud ----- + op.add_column( + "documents", sa.Column("service_request_id", sa.Integer(), nullable=True), schema=SCHEMA + ) + op.create_foreign_key( + "fk_crm_documents_service_request_id", "documents", "service_requests", + ["service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA, + ) + op.create_index( + "ix_crm_documents_service_request_id", "documents", ["service_request_id"], schema=SCHEMA + ) + + # ----- crm.opportunities: dirección impo/expo + folio + back-link a la solicitud ----- + op.add_column("opportunities", sa.Column("operation_type", sa.String(length=20), nullable=True), schema=SCHEMA) + op.add_column("opportunities", sa.Column("reference", sa.String(length=40), nullable=True), schema=SCHEMA) + op.add_column( + "opportunities", + sa.Column("converted_service_request_id", sa.Integer(), nullable=True), + schema=SCHEMA, + ) + op.create_foreign_key( + "fk_crm_opportunities_converted_sr", "opportunities", "service_requests", + ["converted_service_request_id"], ["id"], source_schema=SCHEMA, referent_schema=SCHEMA, + ) + op.create_index( + "ix_crm_opportunities_reference", "opportunities", ["reference"], schema=SCHEMA + ) + + # ----- crm.quotes: variante FCL/LCL para la comparación "Ambas" ----- + op.add_column("quotes", sa.Column("load_type", sa.String(length=10), nullable=True), schema=SCHEMA) + + # ----- crm.folio_counters: consecutivo mensual por compañía y entidad ----- + op.create_table( + "folio_counters", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("entity", sa.String(length=4), nullable=False), + sa.Column("period", sa.String(length=7), nullable=False), + sa.Column("last_number", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.UniqueConstraint( + "tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope" + ), + schema=SCHEMA, + ) + op.create_index("ix_crm_folio_counters_id", "folio_counters", ["id"], schema=SCHEMA) + op.create_index("ix_crm_folio_counters_tenant_id", "folio_counters", ["tenant_id"], schema=SCHEMA) + op.create_index("ix_crm_folio_counters_company_id", "folio_counters", ["company_id"], schema=SCHEMA) + + +def downgrade() -> None: + op.drop_index("ix_crm_folio_counters_company_id", table_name="folio_counters", schema=SCHEMA) + op.drop_index("ix_crm_folio_counters_tenant_id", table_name="folio_counters", schema=SCHEMA) + op.drop_index("ix_crm_folio_counters_id", table_name="folio_counters", schema=SCHEMA) + op.drop_table("folio_counters", schema=SCHEMA) + + op.drop_column("quotes", "load_type", schema=SCHEMA) + + op.drop_index("ix_crm_opportunities_reference", table_name="opportunities", schema=SCHEMA) + op.drop_constraint("fk_crm_opportunities_converted_sr", "opportunities", schema=SCHEMA, type_="foreignkey") + op.drop_column("opportunities", "converted_service_request_id", schema=SCHEMA) + op.drop_column("opportunities", "reference", schema=SCHEMA) + op.drop_column("opportunities", "operation_type", schema=SCHEMA) + + op.drop_index("ix_crm_documents_service_request_id", table_name="documents", schema=SCHEMA) + op.drop_constraint("fk_crm_documents_service_request_id", "documents", schema=SCHEMA, type_="foreignkey") + op.drop_column("documents", "service_request_id", schema=SCHEMA) + + op.drop_index("ix_crm_service_requests_contact_id", table_name="service_requests", schema=SCHEMA) + op.drop_constraint("fk_crm_service_requests_contact_id", "service_requests", schema=SCHEMA, type_="foreignkey") + for name, _col_type, _kwargs in reversed(_SR_COLUMNS): + op.drop_column("service_requests", name, schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index 9f970bd..d2e4f49 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -766,3 +766,85 @@ TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece', 'puerto': 'Puertos donde opera', 'aeropuerto': 'Aeropuertos donde opera', 'aduana': 'Aduanas donde opera'} + +# --------------------------------------------------------------------------- +# Catálogos del proceso comercial (Solicitud de servicio → Cotización). +# Alimentan los selects de la solicitud y del ciclo Oportunidad→Cotización. +# is_system = catálogos base que el cliente no puede borrar (sólo activar/desactivar). +# --------------------------------------------------------------------------- +GLOBAL_CATALOGS.update({ + 'tipo_operacion': {'label': 'Tipo de operación', + 'is_system': True, + 'items': [{'code': 'importacion', 'label': 'Importación'}, + {'code': 'exportacion', 'label': 'Exportación'}]}, + 'medio_transporte': {'label': 'Medio de transporte', + 'is_system': True, + 'items': [{'code': 'maritimo', 'label': 'Marítimo'}, + {'code': 'aereo', 'label': 'Aéreo'}, + {'code': 'terrestre', 'label': 'Terrestre'}, + {'code': 'ferroviario', 'label': 'Ferroviario'}, + {'code': 'multimodal', 'label': 'Multimodal'}]}, + 'tipo_servicio': {'label': 'Tipo de servicio', + 'is_system': True, + 'items': [{'code': 'puerto_puerto', 'label': 'Puerto a puerto'}, + {'code': 'puerto_puerta', 'label': 'Puerto a puerta'}, + {'code': 'puerta_puerto', 'label': 'Puerta a puerto'}, + {'code': 'puerta_puerta', 'label': 'Puerta a puerta'}]}, + 'prioridad': {'label': 'Prioridad', + 'is_system': False, + 'items': [{'code': 'baja', 'label': 'Baja'}, + {'code': 'normal', 'label': 'Normal'}, + {'code': 'alta', 'label': 'Alta'}, + {'code': 'urgente', 'label': 'Urgente'}]}, + 'tipo_mercancia': {'label': 'Tipo de mercancía', + 'is_system': False, + 'items': [{'code': 'general', 'label': 'Carga general'}, + {'code': 'perecedera', 'label': 'Perecedera'}, + {'code': 'peligrosa', 'label': 'Peligrosa (IMO)'}, + {'code': 'refrigerada', 'label': 'Refrigerada'}, + {'code': 'granel', 'label': 'Granel'}, + {'code': 'sobredimensionada', 'label': 'Sobredimensionada'}, + {'code': 'valiosa', 'label': 'Valiosa'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'unidad_medida': {'label': 'Unidad de medida', + 'is_system': False, + 'items': [{'code': 'cm', 'label': 'Centímetros (cm)'}, + {'code': 'm', 'label': 'Metros (m)'}, + {'code': 'in', 'label': 'Pulgadas (in)'}, + {'code': 'ft', 'label': 'Pies (ft)'}, + {'code': 'kg', 'label': 'Kilogramos (kg)'}, + {'code': 'lb', 'label': 'Libras (lb)'}, + {'code': 'm3', 'label': 'Metros cúbicos (m³)'}]}, + 'tipo_embalaje': {'label': 'Tipo de embalaje', + 'is_system': False, + 'items': [{'code': 'caja', 'label': 'Caja'}, + {'code': 'pallet', 'label': 'Pallet'}, + {'code': 'tarima', 'label': 'Tarima'}, + {'code': 'huacal', 'label': 'Huacal'}, + {'code': 'saco', 'label': 'Saco'}, + {'code': 'tambor', 'label': 'Tambor'}, + {'code': 'rollo', 'label': 'Rollo'}, + {'code': 'atado', 'label': 'Atado'}, + {'code': 'granel', 'label': 'Granel'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'servicio_adicional': {'label': 'Servicios adicionales', + 'is_system': False, + 'items': [{'code': 'seguro', 'label': 'Seguro de la mercancía'}, + {'code': 'despacho_aduanal', 'label': 'Despacho aduanal'}, + {'code': 'transporte_terrestre', 'label': 'Transporte terrestre'}, + {'code': 'almacenaje', 'label': 'Almacenaje'}, + {'code': 'maniobras', 'label': 'Maniobras'}, + {'code': 'custodia', 'label': 'Custodia'}, + {'code': 'revalidacion', 'label': 'Revalidación'}, + {'code': 'inspeccion', 'label': 'Inspección'}, + {'code': 'otro', 'label': 'Otro'}]}, + 'tipo_documento': {'label': 'Tipo de documento', + 'is_system': False, + 'items': [{'code': 'factura_comercial', 'label': 'Factura comercial'}, + {'code': 'packing_list', 'label': 'Packing list'}, + {'code': 'certificado_origen', 'label': 'Certificado de origen'}, + {'code': 'hoja_seguridad_msds', 'label': 'Hoja de seguridad (MSDS)'}, + {'code': 'ficha_tecnica', 'label': 'Ficha técnica'}, + {'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'}, + {'code': 'otro', 'label': 'Otro'}]}, +}) diff --git a/backend/api/v1/modules/crm/common/__init__.py b/backend/api/v1/modules/crm/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/common/folios.py b/backend/api/v1/modules/crm/common/folios.py new file mode 100644 index 0000000..281b058 --- /dev/null +++ b/backend/api/v1/modules/crm/common/folios.py @@ -0,0 +1,92 @@ +"""Folios auto-generados del ciclo comercial (Oportunidad → Solicitud → Cotización → Operación). + +Formato: ``{LETRA}{AAAA}-{MM}-{NNN}-{DIR}`` (ej. ``O2025-08-001-E``): +- LETRA: entidad — ``O`` Oportunidad, ``S`` Solicitud, ``C`` Cotización, ``OP`` Operación/Embarque. +- ``AAAA-MM``: año-mes de creación. +- ``NNN``: consecutivo **mensual** por compañía y por entidad (reinicia cada mes). +- ``DIR``: ``I`` importación / ``E`` exportación (``X`` si aún no se define la dirección). + +El consecutivo se toma de ``crm.folio_counters`` con bloqueo de fila para evitar +duplicados por concurrencia. En SQLite (pruebas) el ``FOR UPDATE`` se ignora sin error; +la unicidad la garantiza el índice único (tenant, company, entity, period). +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import Integer, String, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin +from core.database import Base + +# Entidades válidas y su letra de folio. +ENTITIES = ("O", "S", "C", "OP") +# Mapa dirección de operación → sufijo del folio. +_DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"} + + +class FolioCounter(Base, TenantScopedMixin, BaseTimestampMixin): + """Consecutivo mensual por compañía y entidad para armar los folios del ciclo.""" + + __tablename__ = "folio_counters" + __table_args__ = ( + UniqueConstraint( + "tenant_id", "company_id", "entity", "period", name="uq_crm_folio_counters_scope" + ), + {"schema": "crm"}, + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + entity: Mapped[str] = mapped_column(String(4), nullable=False) # O | S | C | OP + period: Mapped[str] = mapped_column(String(7), nullable=False) # 'AAAA-MM' + last_number: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + + +def direction_suffix(direction: str | None) -> str: + """Devuelve la letra de dirección del folio (I/E) o 'X' si no está definida.""" + return _DIRECTION_SUFFIX.get(direction or "", "X") + + +def next_folio( + db, + tenant_id: int, + company_id: int, + entity: str, + direction: str | None, + on_date: date | None = None, +) -> str: + """Genera el siguiente folio de una entidad, incrementando su consecutivo mensual. + + Reserva el número dentro de la transacción activa (no hace commit): el ``create_*`` + que lo invoca es quien confirma junto con la fila recién creada. + """ + if entity not in ENTITIES: + raise ValueError(f"Entidad de folio inválida: {entity!r}") + on_date = on_date or date.today() + period = on_date.strftime("%Y-%m") + + counter = ( + db.query(FolioCounter) + .filter( + FolioCounter.tenant_id == tenant_id, + FolioCounter.company_id == company_id, + FolioCounter.entity == entity, + FolioCounter.period == period, + ) + .with_for_update() + .first() + ) + if counter is None: + counter = FolioCounter( + tenant_id=tenant_id, company_id=company_id, entity=entity, period=period, last_number=0 + ) + db.add(counter) + db.flush() + + counter.last_number = (counter.last_number or 0) + 1 + db.flush() + + sequence = f"{counter.last_number:03d}" + return f"{entity}{period}-{sequence}-{direction_suffix(direction)}" diff --git a/backend/api/v1/modules/crm/documents/models.py b/backend/api/v1/modules/crm/documents/models.py index b07b20d..4cb4dfd 100644 --- a/backend/api/v1/modules/crm/documents/models.py +++ b/backend/api/v1/modules/crm/documents/models.py @@ -22,6 +22,10 @@ class Document(Base, TenantScopedMixin, TimestampMixin): supplier_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True ) + # Documento adjunto a una solicitud de servicio (factura, packing list, MSDS, etc.) + service_request_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True + ) # constancia_fiscal | acta_constitutiva | identificacion | comprobante_domicilio | # contrato | presentacion | certificacion | licencia | convenio | tarifario | otro doc_type: Mapped[str] = mapped_column(String(60), nullable=False) diff --git a/backend/api/v1/modules/crm/opportunities/dto.py b/backend/api/v1/modules/crm/opportunities/dto.py index 24e0620..48a533d 100644 --- a/backend/api/v1/modules/crm/opportunities/dto.py +++ b/backend/api/v1/modules/crm/opportunities/dto.py @@ -17,6 +17,7 @@ class OpportunityCreate(BaseModel): source: str | None = Field(None, max_length=60) owner_user_id: str | None = Field(None, max_length=64) notes: str | None = None + operation_type: str | None = Field(None, max_length=20) # importacion | exportacion class OpportunityUpdate(BaseModel): @@ -34,6 +35,7 @@ class OpportunityUpdate(BaseModel): source: str | None = Field(None, max_length=60) owner_user_id: str | None = Field(None, max_length=64) notes: str | None = None + operation_type: str | None = Field(None, max_length=20) class OpportunityMove(BaseModel): @@ -61,6 +63,9 @@ class OpportunityResponse(BaseModel): source: str | None owner_user_id: str | None notes: str | None + operation_type: str | None = None + reference: str | None = None + converted_service_request_id: int | None = None tenant_id: int company_id: int created_at: datetime diff --git a/backend/api/v1/modules/crm/opportunities/models.py b/backend/api/v1/modules/crm/opportunities/models.py index d9939b1..2686511 100644 --- a/backend/api/v1/modules/crm/opportunities/models.py +++ b/backend/api/v1/modules/crm/opportunities/models.py @@ -38,3 +38,10 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin): source: Mapped[str | None] = mapped_column(String(60), nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) + # Dirección de la operación (importacion|exportacion): se hereda a Solicitud→Cotización→Embarque + operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio O... + # Solicitud generada al convertir la oportunidad (back-link idempotente) + converted_service_request_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.service_requests.id"), nullable=True + ) diff --git a/backend/api/v1/modules/crm/opportunities/service.py b/backend/api/v1/modules/crm/opportunities/service.py index f82d8e5..39ff209 100644 --- a/backend/api/v1/modules/crm/opportunities/service.py +++ b/backend/api/v1/modules/crm/opportunities/service.py @@ -4,6 +4,7 @@ from fastapi import HTTPException, status from sqlalchemy.orm import Session from ..accounts.models import Account +from ..common.folios import next_folio from ..contacts.models import Contact from ..pipelines.models import Pipeline, PipelineStage from .dto import OpportunityCreate, OpportunityUpdate @@ -149,6 +150,9 @@ def create_opportunity( if opportunity.stage_id is not None: stage = _get_scoped_stage(db, opportunity.stage_id, tenant_id, company_id) _apply_stage_state(opportunity, stage) + # Folio O... auto-generado (mensual). La dirección impo/expo se hereda al ciclo. + if not opportunity.reference: + opportunity.reference = next_folio(db, tenant_id, company_id, "O", opportunity.operation_type) db.add(opportunity) db.commit() db.refresh(opportunity) diff --git a/backend/api/v1/modules/crm/quotes/dto.py b/backend/api/v1/modules/crm/quotes/dto.py index 5c74b7d..0af9bd4 100644 --- a/backend/api/v1/modules/crm/quotes/dto.py +++ b/backend/api/v1/modules/crm/quotes/dto.py @@ -56,6 +56,7 @@ class QuoteBase(BaseModel): service_request_id: int | None = None account_id: int | None = None currency: str = Field("USD", max_length=3) + load_type: str | None = Field(None, max_length=10) # FCL | LCL (variante de la comparación "Ambas") issue_date: date | None = None valid_until: date | None = None notes: str | None = None @@ -72,6 +73,7 @@ class QuoteUpdate(BaseModel): service_request_id: int | None = None account_id: int | None = None currency: str | None = Field(None, max_length=3) + load_type: str | None = Field(None, max_length=10) issue_date: date | None = None valid_until: date | None = None notes: str | None = None diff --git a/backend/api/v1/modules/crm/quotes/models.py b/backend/api/v1/modules/crm/quotes/models.py index cf86854..653a383 100644 --- a/backend/api/v1/modules/crm/quotes/models.py +++ b/backend/api/v1/modules/crm/quotes/models.py @@ -22,6 +22,8 @@ class Quote(Base, TenantScopedMixin, TimestampMixin): Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True ) currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'USD'")) + # Variante de carga cuando la solicitud es "Ambas": FCL | LCL (NULL si no aplica) + load_type: Mapped[str | None] = mapped_column(String(10), nullable=True) # borrador | enviada | aceptada | rechazada status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True) issue_date: Mapped[date | None] = mapped_column(Date, nullable=True) diff --git a/backend/api/v1/modules/crm/quotes/pdf_service.py b/backend/api/v1/modules/crm/quotes/pdf_service.py index 4ed0678..01cde30 100644 --- a/backend/api/v1/modules/crm/quotes/pdf_service.py +++ b/backend/api/v1/modules/crm/quotes/pdf_service.py @@ -59,6 +59,14 @@ def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) -> return obj +def _compose_place(city: str | None, country: str | None, port: str | None) -> str | None: + """Arma 'Ciudad, PAÍS (Puerto)' con las partes que existan (ruta estructurada).""" + head = ", ".join(p for p in (city, country) if p) + if port: + head = f"{head} ({port})" if head else port + return head or None + + def _company_row(db: Session, company_id: int) -> dict: try: row = db.execute( @@ -139,7 +147,8 @@ def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) route = [ ("Operación", sr.operation_type), ("Modo", sr.transport_mode), ("Servicio", sr.service_type), ("Incoterm", sr.incoterm), - ("Origen", sr.origin), ("Destino", sr.destination), + ("Origen", sr.origin or _compose_place(sr.origin_city, sr.origin_country, sr.origin_port)), + ("Destino", sr.destination or _compose_place(sr.destination_city, sr.destination_country, sr.destination_port)), ("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None), ] diff --git a/backend/api/v1/modules/crm/quotes/routes.py b/backend/api/v1/modules/crm/quotes/routes.py index 7693f01..ed335fa 100644 --- a/backend/api/v1/modules/crm/quotes/routes.py +++ b/backend/api/v1/modules/crm/quotes/routes.py @@ -110,6 +110,24 @@ def create_quote( return service.create_quote(db, payload, tenant_id, company_id, _user_id(current_user)) +@router.post( + "/quotes/from-service-request", + response_model=list[QuoteResponse], + status_code=status.HTTP_201_CREATED, +) +def create_quotes_from_service_request( + service_request_id: int = Query(..., description="Solicitud de servicio a cotizar"), + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Genera la(s) cotización(es) desde una solicitud. Si es 'Ambas' devuelve 2 (FCL/LCL).""" + tenant_id = current_user["tenant_id"] + return service.create_quotes_from_service_request( + db, service_request_id, tenant_id, company_id, _user_id(current_user) + ) + + @router.patch("/quotes/{quote_id}", response_model=QuoteResponse) def update_quote( quote_id: int, diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index f4be755..365308d 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import date, datetime, timezone from decimal import Decimal from fastapi import HTTPException, status @@ -6,7 +6,8 @@ from sqlalchemy import func from sqlalchemy.orm import Session from ..accounts.models import Account -from ..service_requests.models import ServiceRequest +from ..common.folios import next_folio +from ..service_requests.models import RateRequest, ServiceRequest from ..suppliers.models import Supplier from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate from .models import Quote, QuoteItem @@ -89,18 +90,104 @@ def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Qu return obj +def _sr_direction(db: Session, service_request_id: int | None) -> str | None: + """Dirección impo/expo heredada de la solicitud asociada (para el folio).""" + if not service_request_id: + return None + sr = db.query(ServiceRequest).filter(ServiceRequest.id == service_request_id).first() + return sr.operation_type if sr else None + + def create_quote( db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Quote: data = payload.model_dump() _validate_refs(db, data, tenant_id, company_id) obj = Quote(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + # Fecha de la cotización: por defecto hoy si no se capturó + if obj.issue_date is None: + obj.issue_date = date.today() + # Folio C... auto-generado (mensual), con la dirección heredada de la solicitud + if not obj.reference: + obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id)) db.add(obj) db.commit() db.refresh(obj) return obj +def create_quotes_from_service_request( + db: Session, service_request_id: int, tenant_id: int, company_id: int, user_id: str | None = None +) -> list[Quote]: + """Genera cotización(es) a partir de una solicitud de servicio. + + Si la solicitud es "Ambas" (FCL y LCL), genera **dos** cotizaciones (una por + variante) para comparar. Cada cotización toma su propio folio C... y hereda la + dirección impo/expo de la solicitud. Los conceptos se siembran desde las + solicitudes de tarifa (RateRequest) capturadas en la solicitud. + """ + sr = ( + db.query(ServiceRequest) + .filter( + ServiceRequest.id == service_request_id, + ServiceRequest.tenant_id == tenant_id, + ServiceRequest.company_id == company_id, + ServiceRequest.deleted_at.is_(None), + ) + .first() + ) + if not sr: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada") + + variants = ["FCL", "LCL"] if (sr.load_type or "").upper() == "AMBAS" else [sr.load_type or None] + rate_requests = ( + db.query(RateRequest) + .filter( + RateRequest.service_request_id == sr.id, + RateRequest.tenant_id == tenant_id, + RateRequest.company_id == company_id, + RateRequest.deleted_at.is_(None), + ) + .all() + ) + + created: list[Quote] = [] + for variant in variants: + quote = Quote( + account_id=sr.account_id, + service_request_id=sr.id, + currency=sr.currency or "USD", + load_type=variant, + status="borrador", + issue_date=date.today(), + notes=sr.client_notes or sr.notes, + owner_user_id=sr.owner_user_id, + reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type), + tenant_id=tenant_id, + company_id=company_id, + created_by=user_id, + updated_by=user_id, + ) + db.add(quote) + db.flush() + for rr in rate_requests: + amount = rr.rate_amount if rr.rate_amount is not None else Decimal(0) + db.add(QuoteItem( + quote_id=quote.id, concept=rr.concept, description=rr.description, + supplier_id=rr.supplier_id, quantity=Decimal(1), + unit_cost=amount, unit_sale=amount, currency=rr.currency, + tenant_id=tenant_id, company_id=company_id, + )) + db.flush() + _recompute_totals(db, quote) + created.append(quote) + + db.commit() + for quote in created: + db.refresh(quote) + return created + + def update_quote( db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None ) -> Quote: diff --git a/backend/api/v1/modules/crm/service_requests/dto.py b/backend/api/v1/modules/crm/service_requests/dto.py index 6eb44b4..7ccb583 100644 --- a/backend/api/v1/modules/crm/service_requests/dto.py +++ b/backend/api/v1/modules/crm/service_requests/dto.py @@ -7,22 +7,65 @@ from pydantic import BaseModel, ConfigDict, Field class ServiceRequestBase(BaseModel): reference: str | None = Field(None, max_length=40) account_id: int | None = None + contact_id: int | None = None opportunity_id: int | None = None operation_type: str = Field(..., max_length=20) # importacion | exportacion transport_mode: str | None = Field(None, max_length=20) service_type: str | None = Field(None, max_length=20) incoterm: str | None = Field(None, max_length=10) + # Ruta legada (texto libre) — se conserva por compatibilidad origin: str | None = Field(None, max_length=160) destination: str | None = Field(None, max_length=160) + # Ruta estructurada (país por catálogo ISO; ciudad/puerto por catálogo o texto) + origin_country: str | None = Field(None, max_length=3) + origin_city: str | None = Field(None, max_length=120) + origin_port: str | None = Field(None, max_length=20) + destination_country: str | None = Field(None, max_length=3) + destination_city: str | None = Field(None, max_length=120) + destination_port: str | None = Field(None, max_length=20) + pickup_location: str | None = Field(None, max_length=255) + delivery_location: str | None = Field(None, max_length=255) cargo_type: str | None = Field(None, max_length=120) - weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # peso bruto volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) - load_type: str | None = Field(None, max_length=10) + load_type: str | None = Field(None, max_length=10) # FCL | LCL | AMBAS container_equipment: str | None = Field(None, max_length=120) + container_count: int | None = Field(None, ge=0) commodity: str | None = None required_date: date | None = None + request_date: date | None = None + estimated_shipment_date: date | None = None + currency: str | None = Field(None, max_length=3) + priority: str | None = Field(None, max_length=20) + # Mercancía + cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + insurance_required: bool = False + hs_code: str | None = Field(None, max_length=20) + goods_origin_country: str | None = Field(None, max_length=3) + hazardous_imo: bool = False + refrigerated: bool = False + stackable: bool = False + # Dimensiones y bultos + pieces_count: int | None = Field(None, ge=0) + boxes_count: int | None = Field(None, ge=0) + pallets_count: int | None = Field(None, ge=0) + net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + measurement_unit: str | None = Field(None, max_length=20) + # LCL + packaging_type: str | None = Field(None, max_length=20) + oversized: bool = False + weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + # Servicios adicionales (códigos del catálogo servicio_adicional) y pago + additional_services: list[str] | None = None + payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None + client_notes: str | None = None + internal_notes: str | None = None status: str = Field("nueva", max_length=20) notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) @@ -38,8 +81,12 @@ class ServiceRequestContactInput(BaseModel): class ServiceRequestFromOpportunityInput(BaseModel): - """Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02).""" - operation_type: str = Field(..., max_length=20) # importacion | exportacion + """Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02). + + La dirección impo/expo se hereda de la oportunidad; ``operation_type`` aquí es + solo un respaldo para oportunidades antiguas que no la tengan capturada. + """ + operation_type: str | None = Field(None, max_length=20) # importacion | exportacion transport_mode: str | None = Field(None, max_length=20) service_type: str | None = Field(None, max_length=20) incoterm: str | None = Field(None, max_length=10) @@ -51,6 +98,7 @@ class ServiceRequestFromOpportunityInput(BaseModel): class ServiceRequestUpdate(BaseModel): reference: str | None = Field(None, max_length=40) account_id: int | None = None + contact_id: int | None = None opportunity_id: int | None = None operation_type: str | None = Field(None, max_length=20) transport_mode: str | None = Field(None, max_length=20) @@ -58,15 +106,51 @@ class ServiceRequestUpdate(BaseModel): incoterm: str | None = Field(None, max_length=10) origin: str | None = Field(None, max_length=160) destination: str | None = Field(None, max_length=160) + origin_country: str | None = Field(None, max_length=3) + origin_city: str | None = Field(None, max_length=120) + origin_port: str | None = Field(None, max_length=20) + destination_country: str | None = Field(None, max_length=3) + destination_city: str | None = Field(None, max_length=120) + destination_port: str | None = Field(None, max_length=20) + pickup_location: str | None = Field(None, max_length=255) + delivery_location: str | None = Field(None, max_length=255) cargo_type: str | None = Field(None, max_length=120) weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) load_type: str | None = Field(None, max_length=10) container_equipment: str | None = Field(None, max_length=120) + container_count: int | None = Field(None, ge=0) commodity: str | None = None required_date: date | None = None + request_date: date | None = None + estimated_shipment_date: date | None = None + currency: str | None = Field(None, max_length=3) + priority: str | None = Field(None, max_length=20) + cargo_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) + insurance_required: bool | None = None + hs_code: str | None = Field(None, max_length=20) + goods_origin_country: str | None = Field(None, max_length=3) + hazardous_imo: bool | None = None + refrigerated: bool | None = None + stackable: bool | None = None + pieces_count: int | None = Field(None, ge=0) + boxes_count: int | None = Field(None, ge=0) + pallets_count: int | None = Field(None, ge=0) + net_weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + length_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + width_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + height_cm: Decimal | None = Field(None, ge=0, max_digits=10, decimal_places=2) + measurement_unit: str | None = Field(None, max_length=20) + packaging_type: str | None = Field(None, max_length=20) + oversized: bool | None = None + weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) + additional_services: list[str] | None = None + payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None + client_notes: str | None = None + internal_notes: str | None = None status: str | None = Field(None, max_length=20) notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) diff --git a/backend/api/v1/modules/crm/service_requests/models.py b/backend/api/v1/modules/crm/service_requests/models.py index 3182e76..e141967 100644 --- a/backend/api/v1/modules/crm/service_requests/models.py +++ b/backend/api/v1/modules/crm/service_requests/models.py @@ -1,6 +1,6 @@ from datetime import date, datetime -from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text +from sqlalchemy import JSON, Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import TenantScopedMixin, TimestampMixin @@ -57,6 +57,55 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin): created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + # ----- Campos del documento maestro de cotización (T2026-08) ----- + # Datos generales + contact_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True + ) + request_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha de la solicitud + currency: Mapped[str | None] = mapped_column(String(3), nullable=True) + priority: Mapped[str | None] = mapped_column(String(20), nullable=True) # baja|normal|alta|urgente + # Ruta (país por catálogo ISO; ciudad/puerto por catálogo o texto libre) + origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) + origin_city: Mapped[str | None] = mapped_column(String(120), nullable=True) + origin_port: Mapped[str | None] = mapped_column(String(20), nullable=True) + destination_country: Mapped[str | None] = mapped_column(String(3), nullable=True) + destination_city: Mapped[str | None] = mapped_column(String(120), nullable=True) + destination_port: Mapped[str | None] = mapped_column(String(20), nullable=True) + pickup_location: Mapped[str | None] = mapped_column(String(255), nullable=True) + delivery_location: Mapped[str | None] = mapped_column(String(255), nullable=True) + estimated_shipment_date: Mapped[date | None] = mapped_column(Date, nullable=True) + # Mercancía + cargo_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) + insurance_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + hs_code: Mapped[str | None] = mapped_column(String(20), nullable=True) # fracción arancelaria + goods_origin_country: Mapped[str | None] = mapped_column(String(3), nullable=True) # país de origen de la mercancía + hazardous_imo: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + refrigerated: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + stackable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + # Dimensiones y bultos + pieces_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + boxes_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + pallets_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + net_weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # peso neto (weight = bruto) + length_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + width_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + height_cm: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True) + measurement_unit: Mapped[str | None] = mapped_column(String(20), nullable=True) + # FCL + container_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + # LCL + packaging_type: Mapped[str | None] = mapped_column(String(20), nullable=True) + oversized: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false")) + weight_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) + volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) + # Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago + additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True) + payment_method: Mapped[str | None] = mapped_column(String(20), nullable=True) + # Notas + client_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + internal_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + class RateRequest(Base, TenantScopedMixin, TimestampMixin): """Solicitud de tarifa a un proveedor para una solicitud de servicio (Diagrama 1, paso 6).""" diff --git a/backend/api/v1/modules/crm/service_requests/service.py b/backend/api/v1/modules/crm/service_requests/service.py index 016dbed..42b096a 100644 --- a/backend/api/v1/modules/crm/service_requests/service.py +++ b/backend/api/v1/modules/crm/service_requests/service.py @@ -5,6 +5,8 @@ from sqlalchemy.orm import Session from ..accounts.models import Account from ..catalogs.data import INCOTERM_CODES +from ..common.folios import next_folio +from ..contacts.models import Contact from ..opportunities.models import Opportunity from ..suppliers.models import Supplier from .dto import ( @@ -37,6 +39,8 @@ def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None: if not _exists(db, Account, data.get("account_id"), tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe") + if not _exists(db, Contact, data.get("contact_id"), tenant_id, company_id): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El contacto asociado no existe") if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe") if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id): @@ -103,6 +107,9 @@ def create_service_request( data = payload.model_dump() _validate_request_refs(db, data, tenant_id, company_id) obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + # Folio S... auto-generado (mensual) si no viene uno explícito + if not obj.reference: + obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type) db.add(obj) db.commit() db.refresh(obj) @@ -166,10 +173,24 @@ def create_from_opportunity( ) if not opp: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada") + + # Idempotente: si la oportunidad ya se convirtió, devuelve la misma solicitud + if opp.converted_service_request_id: + existing = get_service_request(db, opp.converted_service_request_id, tenant_id, company_id) + return existing + + # La dirección impo/expo se hereda de la oportunidad (respaldo: el payload) + operation_type = opp.operation_type or payload.operation_type + if not operation_type: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Define la dirección (importación/exportación) en la oportunidad para convertirla", + ) obj = ServiceRequest( account_id=opp.account_id, + contact_id=opp.contact_id, opportunity_id=opp.id, - operation_type=payload.operation_type, + operation_type=operation_type, transport_mode=payload.transport_mode, service_type=payload.service_type, incoterm=payload.incoterm, @@ -178,12 +199,16 @@ def create_from_opportunity( status="nueva", notes=payload.notes, owner_user_id=opp.owner_user_id, + reference=next_folio(db, tenant_id, company_id, "S", operation_type), tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id, ) db.add(obj) + db.flush() + # Back-link para cerrar el ciclo Oportunidad→Solicitud (y garantizar idempotencia) + opp.converted_service_request_id = obj.id db.commit() db.refresh(obj) return obj diff --git a/backend/api/v1/modules/ops/shipments/routes.py b/backend/api/v1/modules/ops/shipments/routes.py index 66985fa..265bd01 100644 --- a/backend/api/v1/modules/ops/shipments/routes.py +++ b/backend/api/v1/modules/ops/shipments/routes.py @@ -65,11 +65,14 @@ def create_shipment( def create_shipment_from_quote( quote_id: int = Query(..., description="Cotización aceptada a liberar"), company_id: int = Query(..., description="Company ID"), + operation_type: str | None = Query(None, description="Confirma la dirección: importacion | exportacion"), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db), ): tenant_id = current_user["tenant_id"] - return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user)) + return service.create_shipment_from_quote( + db, quote_id, tenant_id, company_id, _user_id(current_user), operation_type=operation_type + ) @router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse) diff --git a/backend/api/v1/modules/ops/shipments/service.py b/backend/api/v1/modules/ops/shipments/service.py index 75f7943..2372cd1 100644 --- a/backend/api/v1/modules/ops/shipments/service.py +++ b/backend/api/v1/modules/ops/shipments/service.py @@ -5,10 +5,14 @@ from sqlalchemy import func from sqlalchemy.orm import Session from api.v1.modules.crm.accounts.models import Account +from api.v1.modules.crm.common.folios import next_folio from api.v1.modules.crm.quotes.models import Quote from api.v1.modules.crm.service_requests.models import ServiceRequest from api.v1.modules.crm.suppliers.models import Supplier +# Direcciones válidas de la operación (para validar y sembrar hitos). +_OPERATION_TYPES = ("importacion", "exportacion") + from .dto import ( ShipmentCloseInput, ShipmentCreate, @@ -173,9 +177,20 @@ def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: i def create_shipment_from_quote( - db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None + db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None, + operation_type: str | None = None, ) -> Shipment: - """Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.""" + """Liberar a Operaciones: crea el embarque a partir de una cotización aceptada. + + La dirección impo/expo se confirma al liberar (``operation_type``) y, si no se + envía, se hereda de la solicitud. Con la dirección resuelta se genera el folio + ``OP...`` y se siembran automáticamente los hitos del proceso (Diagramas 2 y 3). + """ + if operation_type is not None and operation_type not in _OPERATION_TYPES: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Tipo de operación inválido: usa 'importacion' o 'exportacion'", + ) quote = ( db.query(Quote) .filter( @@ -198,12 +213,15 @@ def create_shipment_from_quote( if quote.service_request_id: sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first() + # La dirección enviada al liberar manda; si no viene, se hereda de la solicitud + resolved = operation_type or (sr.operation_type if sr else None) + shipment = Shipment( - reference=quote.reference, + reference=next_folio(db, tenant_id, company_id, "OP", resolved), quote_id=quote.id, service_request_id=quote.service_request_id, account_id=quote.account_id, - operation_type=sr.operation_type if sr else None, + operation_type=resolved, transport_mode=sr.transport_mode if sr else None, service_type=sr.service_type if sr else None, incoterm=sr.incoterm if sr else None, @@ -220,6 +238,13 @@ def create_shipment_from_quote( db.add(shipment) if sr: sr.status = "liberada" + db.flush() + # Siembra automática de hitos si ya se conoce la dirección de la operación + for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])): + db.add(ShipmentEvent( + shipment_id=shipment.id, event_type=event_type, title=title, kind=kind, + status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id, + )) db.commit() db.refresh(shipment) return shipment diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 70d2eaa..dd9bbf5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,6 +28,8 @@ from core.database import Base # noqa: E402 import api.v1.modules.crm.accounts.models # noqa: E402,F401 import api.v1.modules.crm.activities.models # noqa: E402,F401 import api.v1.modules.crm.addresses.models # noqa: E402,F401 +import api.v1.modules.crm.catalogs.models # noqa: E402,F401 +import api.v1.modules.crm.common.folios # noqa: E402,F401 import api.v1.modules.crm.contacts.models # noqa: E402,F401 import api.v1.modules.crm.documents.models # noqa: E402,F401 import api.v1.modules.crm.leads.models # noqa: E402,F401 diff --git a/backend/tests/test_catalogs_seed.py b/backend/tests/test_catalogs_seed.py new file mode 100644 index 0000000..a0f55c1 --- /dev/null +++ b/backend/tests/test_catalogs_seed.py @@ -0,0 +1,49 @@ +"""Pruebas de la siembra idempotente de catálogos globales del CRM.""" + +from api.v1.modules.crm.catalogs.models import CatalogItem +from api.v1.modules.crm.catalogs.seed import seed_global_catalogs + +# Catálogos nuevos del proceso comercial y una clave base que debe existir en cada uno. +NEW_CATALOGS = { + "tipo_operacion": "importacion", + "medio_transporte": "maritimo", + "tipo_servicio": "puerto_puerto", + "prioridad": "urgente", + "tipo_mercancia": "peligrosa", + "unidad_medida": "kg", + "tipo_embalaje": "pallet", + "servicio_adicional": "seguro", + "tipo_documento": "factura_comercial", +} + + +def _codes(db, catalog: str) -> set[str]: + return { + row.code + for row in db.query(CatalogItem.code).filter( + CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None) + ) + } + + +def test_seed_creates_new_catalogs(db): + seed_global_catalogs(db) + for catalog, base_code in NEW_CATALOGS.items(): + codes = _codes(db, catalog) + assert codes, f"El catálogo {catalog} quedó vacío" + assert base_code in codes, f"Falta la clave base {base_code} en {catalog}" + + +def test_seed_is_idempotent(db): + first = seed_global_catalogs(db) + assert first, "La primera corrida debió sembrar filas" + second = seed_global_catalogs(db) + assert second == {}, "La segunda corrida no debe agregar filas nuevas" + + +def test_pais_catalog_populated(db): + """El catálogo pais alimenta Origen/Destino de la solicitud (decisión 6).""" + seed_global_catalogs(db) + codes = _codes(db, "pais") + assert len(codes) > 100 + assert "MEX" in codes diff --git a/backend/tests/test_folios.py b/backend/tests/test_folios.py new file mode 100644 index 0000000..430659c --- /dev/null +++ b/backend/tests/test_folios.py @@ -0,0 +1,43 @@ +"""Pruebas del generador de folios del ciclo comercial (next_folio).""" + +from datetime import date + +from api.v1.modules.crm.common.folios import next_folio + +T, C = 1, 1 + + +def test_folio_format_and_direction(db): + folio = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 15)) + assert folio == "O2025-08-001-E" + imp = next_folio(db, T, C, "S", "importacion", on_date=date(2025, 8, 15)) + assert imp == "S2025-08-001-I" + sin_dir = next_folio(db, T, C, "C", None, on_date=date(2025, 8, 15)) + assert sin_dir == "C2025-08-001-X" + + +def test_folio_monthly_consecutive_per_entity(db): + a = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 1)) + b = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20)) + assert a == "O2025-08-001-E" + assert b == "O2025-08-002-E" # mismo mes, mismo entity → +1 + + +def test_folio_resets_on_month_change(db): + next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20)) + sep = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 9, 1)) + assert sep == "O2025-09-001-E" # nuevo mes → reinicia consecutivo + + +def test_folio_entities_do_not_share_counter(db): + o = next_folio(db, T, C, "O", "exportacion", on_date=date(2025, 8, 20)) + s = next_folio(db, T, C, "S", "exportacion", on_date=date(2025, 8, 20)) + op = next_folio(db, T, C, "OP", "importacion", on_date=date(2025, 8, 20)) + assert o == "O2025-08-001-E" + assert s == "S2025-08-001-E" # entity distinto → su propio consecutivo + assert op == "OP2025-08-001-I" + + +def test_folio_unique_across_many(db): + folios = {next_folio(db, T, C, "C", "importacion", on_date=date(2025, 8, 10)) for _ in range(25)} + assert len(folios) == 25 # sin duplicados diff --git a/backend/tests/test_quotes.py b/backend/tests/test_quotes.py index 0d10639..aa265ec 100644 --- a/backend/tests/test_quotes.py +++ b/backend/tests/test_quotes.py @@ -1,9 +1,13 @@ +from datetime import date from decimal import Decimal +import pytest +from fastapi import HTTPException + from api.v1.modules.crm.quotes import service from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate from api.v1.modules.crm.service_requests import service as sr_service -from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate +from api.v1.modules.crm.service_requests.dto import RateRequestCreate, ServiceRequestCreate T, C = 1, 1 @@ -44,3 +48,70 @@ def test_accept_quote_updates_service_request(db): # la solicitud asociada queda aceptada sr = sr_service.get_service_request(db, sr.id, T, C) assert sr.status == "aceptada" + + +# ----- Solicitud → Cotización ----- + +def _sr_with_rates(db, load_type="FCL"): + sr = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion", load_type=load_type, currency="USD"), T, C + ) + sr_service.create_rate_request( + db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional", + rate_amount=1200, currency="USD"), T, C + ) + sr_service.create_rate_request( + db, RateRequestCreate(service_request_id=sr.id, concept="despacho_aduanal", + rate_amount=300, currency="USD"), T, C + ) + return sr + + +def test_quote_from_service_request_seeds_items(db): + sr = _sr_with_rates(db) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C, user_id="dev") + assert len(quotes) == 1 + q = quotes[0] + assert q.service_request_id == sr.id + assert q.reference.startswith("C") and q.reference.endswith("-I") + items = service.get_quote_items(db, q.id, T, C) + assert len(items) == 2 + assert float(q.total_sale) == 1500.0 # 1200 + 300 + + +def test_quote_from_service_request_without_rates(db): + sr = sr_service.create_service_request( + db, ServiceRequestCreate(operation_type="exportacion", load_type="FCL"), T, C + ) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + assert len(quotes) == 1 + assert service.get_quote_items(db, quotes[0].id, T, C) == [] + + +def test_quote_from_service_request_not_found(db): + with pytest.raises(HTTPException) as exc: + service.create_quotes_from_service_request(db, 999, T, C) + assert exc.value.status_code == 404 + + +def test_quote_from_service_request_ambas_genera_dos(db): + sr = _sr_with_rates(db, load_type="AMBAS") + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + assert len(quotes) == 2 + variants = {q.load_type for q in quotes} + assert variants == {"FCL", "LCL"} + # cada variante siembra sus propios conceptos y toma su propio folio + assert quotes[0].reference != quotes[1].reference + for q in quotes: + assert len(service.get_quote_items(db, q.id, T, C)) == 2 + + +def test_quote_from_service_request_sets_issue_date_today(db): + sr = _sr_with_rates(db) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + assert quotes[0].issue_date == date.today() + + +def test_create_quote_sets_issue_date_today(db): + q = service.create_quote(db, QuoteCreate(reference="COT-DATE"), T, C) + assert q.issue_date == date.today() diff --git a/backend/tests/test_service_requests.py b/backend/tests/test_service_requests.py index 2e798d8..1ea641f 100644 --- a/backend/tests/test_service_requests.py +++ b/backend/tests/test_service_requests.py @@ -3,10 +3,15 @@ from fastapi import HTTPException from api.v1.modules.crm.accounts import service as accounts_service from api.v1.modules.crm.accounts.dto import AccountCreate +from api.v1.modules.crm.contacts import service as contacts_service +from api.v1.modules.crm.contacts.dto import ContactCreate +from api.v1.modules.crm.opportunities import service as opp_service +from api.v1.modules.crm.opportunities.dto import OpportunityCreate from api.v1.modules.crm.service_requests import service from api.v1.modules.crm.service_requests.dto import ( RateRequestCreate, ServiceRequestCreate, + ServiceRequestFromOpportunityInput, ServiceRequestUpdate, ) @@ -64,3 +69,88 @@ def test_update_service_request_status(db): sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) upd = service.update_service_request(db, sr.id, ServiceRequestUpdate(status="en_analisis"), T, C) assert upd.status == "en_analisis" + + +# ----- Campos del documento maestro de cotización ----- + +def test_create_service_request_new_fields(db): + sr = service.create_service_request( + db, + ServiceRequestCreate( + operation_type="importacion", load_type="LCL", priority="alta", + origin_country="CHN", origin_city="Shanghai", + destination_country="MEX", destination_city="Manzanillo", + cargo_value=15000, insurance_required=True, hazardous_imo=True, + pieces_count=12, net_weight=800, measurement_unit="kg", + additional_services=["seguro", "despacho_aduanal"], + payment_method="99", client_notes="Manejo con cuidado", + ), + T, C, + ) + assert sr.origin_country == "CHN" + assert sr.insurance_required is True + assert sr.hazardous_imo is True + assert sr.additional_services == ["seguro", "despacho_aduanal"] + assert sr.pieces_count == 12 + + +def test_service_request_rejects_unknown_contact(db): + with pytest.raises(HTTPException) as exc: + service.create_service_request( + db, ServiceRequestCreate(operation_type="importacion", contact_id=999), T, C + ) + assert exc.value.status_code == 422 + + +def test_service_request_generates_folio(db): + sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + assert sr.reference is not None + assert sr.reference.startswith("S") + assert sr.reference.endswith("-E") + + +def test_service_request_accepts_ambas(db): + sr = service.create_service_request( + db, ServiceRequestCreate(operation_type="exportacion", load_type="AMBAS"), T, C + ) + assert sr.load_type == "AMBAS" + + +def test_from_opportunity_inherits_operation_type_and_backlink(db): + acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C) + contact = contacts_service.create_contact( + db, ContactCreate(account_id=acc.id, first_name="Ana"), T, C + ) + opp = opp_service.create_opportunity( + db, + OpportunityCreate(name="Negocio", account_id=acc.id, contact_id=contact.id, + operation_type="importacion"), + T, C, + ) + sr = service.create_from_opportunity( + db, opp.id, ServiceRequestFromOpportunityInput(transport_mode="aereo"), T, C, user_id="dev" + ) + # Hereda dirección y contacto de la oportunidad + assert sr.operation_type == "importacion" + assert sr.contact_id == contact.id + assert sr.opportunity_id == opp.id + assert sr.reference.startswith("S") and sr.reference.endswith("-I") + # Back-link en la oportunidad + refreshed = opp_service.get_opportunity(db, opp.id, T, C) + assert refreshed.converted_service_request_id == sr.id + + +def test_from_opportunity_idempotent(db): + opp = opp_service.create_opportunity( + db, OpportunityCreate(name="Negocio", operation_type="exportacion"), T, C + ) + first = service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C) + second = service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C) + assert first.id == second.id # no crea una segunda solicitud + + +def test_from_opportunity_without_direction_fails(db): + opp = opp_service.create_opportunity(db, OpportunityCreate(name="Sin dirección"), T, C) + with pytest.raises(HTTPException) as exc: + service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C) + assert exc.value.status_code == 422 diff --git a/backend/tests/test_shipments.py b/backend/tests/test_shipments.py index b987b38..4364a53 100644 --- a/backend/tests/test_shipments.py +++ b/backend/tests/test_shipments.py @@ -58,3 +58,47 @@ def test_shipment_rejects_unknown_quote(db): with pytest.raises(HTTPException) as exc: service.create_shipment(db, ShipmentCreate(quote_id=999), T, C) assert exc.value.status_code == 422 + + +# ----- Cotización → Operación: dirección IMPO/EXPO + auto-hitos + folio OP ----- + +def _accepted_quote(db, sr=None): + kwargs = {"reference": "COT-Z"} + if sr is not None: + kwargs["service_request_id"] = sr.id + q = quotes_service.create_quote(db, QuoteCreate(**kwargs), T, C) + quotes_service.accept_quote(db, q.id, T, C) + return q + + +def test_from_quote_explicit_operation_type_generates_milestones(db): + q = _accepted_quote(db) + shipment = service.create_shipment_from_quote(db, q.id, T, C, operation_type="importacion") + assert shipment.operation_type == "importacion" + assert shipment.reference.startswith("OP") and shipment.reference.endswith("-I") + events = service.get_shipment_events(db, T, C, shipment.id) + assert len(events) == 11 # hitos de importación (Diagrama 3) + + +def test_from_quote_inherits_sr_operation_type(db): + sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + q = _accepted_quote(db, sr=sr) + shipment = service.create_shipment_from_quote(db, q.id, T, C) # sin operation_type explícito + assert shipment.operation_type == "exportacion" + events = service.get_shipment_events(db, T, C, shipment.id) + assert len(events) == 19 # hitos de exportación (Diagrama 2) + + +def test_from_quote_no_operation_type_no_milestones(db): + q = _accepted_quote(db) # sin solicitud → sin dirección + shipment = service.create_shipment_from_quote(db, q.id, T, C) + assert shipment.operation_type is None + assert service.get_shipment_events(db, T, C, shipment.id) == [] # sin hitos, sin excepción + assert shipment.reference.endswith("-X") + + +def test_from_quote_invalid_operation_type(db): + q = _accepted_quote(db) + with pytest.raises(HTTPException) as exc: + service.create_shipment_from_quote(db, q.id, T, C, operation_type="foo") + assert exc.value.status_code == 422 diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index e190fde..184c5fb 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -11,6 +11,7 @@ export interface ServiceRequest { id: number; reference: string | null; account_id: number | null; + contact_id: number | null; opportunity_id: number | null; operation_type: string; transport_mode: string | null; @@ -18,15 +19,51 @@ export interface ServiceRequest { incoterm: string | null; origin: string | null; destination: string | null; + origin_country: string | null; + origin_city: string | null; + origin_port: string | null; + destination_country: string | null; + destination_city: string | null; + destination_port: string | null; + pickup_location: string | null; + delivery_location: string | null; cargo_type: string | null; weight: number | null; volume: number | null; load_type: string | null; container_equipment: string | null; + container_count: number | null; commodity: string | null; required_date: string | null; + request_date: string | null; + estimated_shipment_date: string | null; + currency: string | null; + priority: string | null; + cargo_value: number | null; + insurance_required: boolean; + hs_code: string | null; + goods_origin_country: string | null; + hazardous_imo: boolean; + refrigerated: boolean; + stackable: boolean; + pieces_count: number | null; + boxes_count: number | null; + pallets_count: number | null; + net_weight: number | null; + length_cm: number | null; + width_cm: number | null; + height_cm: number | null; + measurement_unit: string | null; + packaging_type: string | null; + oversized: boolean; + weight_per_pallet: number | null; + volume_per_pallet: number | null; + additional_services: string[] | null; + payment_method: string | null; destination_agent_id: number | null; requirements: string | null; + client_notes: string | null; + internal_notes: string | null; first_contact_at: string | null; first_contact_notes: string | null; status: ServiceRequestStatus; @@ -70,6 +107,7 @@ export interface Quote { service_request_id: number | null; account_id: number | null; currency: string; + load_type: string | null; status: QuoteStatus; issue_date: string | null; valid_until: string | null; @@ -133,7 +171,7 @@ export const serviceRequestsAPI = { unwrap(api.post(`/v1/crm/service-requests/${id}/contact?${qp(companyId)}`, { notes })), requote: (id: number, companyId: number) => unwrap(api.post(`/v1/crm/service-requests/${id}/requote?${qp(companyId)}`, {})), - fromOpportunity: (opportunityId: number, data: { operation_type: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) => + fromOpportunity: (opportunityId: number, data: { operation_type?: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) => unwrap(api.post(`/v1/crm/service-requests/from-opportunity?${qp(companyId, { opportunity_id: opportunityId })}`, data)), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/service-requests/${id}?${qp(companyId)}`)) }; @@ -156,6 +194,8 @@ export const quotesAPI = { accept: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/accept?${qp(companyId)}`, {})), reject: (id: number, companyId: number) => unwrap(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})), clone: (id: number, companyId: number) => unwrap(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})), + fromServiceRequest: (serviceRequestId: number, companyId: number) => + unwrap(api.post(`/v1/crm/quotes/from-service-request?${qp(companyId, { service_request_id: serviceRequestId })}`, {})), remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)), items: (quoteId: number, companyId: number) => unwrap(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)), pdfBlob: (id: number, companyId: number) => (api as any).getBlob(`/v1/crm/quotes/${id}/pdf?${qp(companyId)}`) as Promise, diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index 58f0870..84d2a77 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -265,6 +265,9 @@ export interface Opportunity { source: string | null; owner_user_id: string | null; notes: string | null; + operation_type: string | null; + reference: string | null; + converted_service_request_id: number | null; tenant_id: number; company_id: number; created_at: string; diff --git a/frontend/src/lib/api/ops/index.ts b/frontend/src/lib/api/ops/index.ts index 7eb309c..c9084aa 100644 --- a/frontend/src/lib/api/ops/index.ts +++ b/frontend/src/lib/api/ops/index.ts @@ -106,8 +106,8 @@ export const shipmentsAPI = { unwrap(api.get(`/v1/ops/shipments?${qp(companyId, params)}`)), get: (id: number, companyId: number) => unwrap(api.get(`/v1/ops/shipments/${id}?${qp(companyId)}`)), create: (data: ShipmentInput, companyId: number) => unwrap(api.post(`/v1/ops/shipments?${qp(companyId)}`, data)), - createFromQuote: (quoteId: number, companyId: number) => - unwrap(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId })}`, {})), + createFromQuote: (quoteId: number, companyId: number, operationType?: string) => + unwrap(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId, operation_type: operationType })}`, {})), update: (id: number, data: Partial, companyId: number) => unwrap(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)), reschedule: (id: number, data: { etd?: string | null; cutoff_date?: string | null; reason?: string | null }, companyId: number) => unwrap(api.post(`/v1/ops/shipments/${id}/reschedule?${qp(companyId)}`, data)), diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte new file mode 100644 index 0000000..6f86a54 --- /dev/null +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -0,0 +1,155 @@ + + +{#if tab === 'datos'} +
+ + + + + + + + + {#if form.status} + + {/if} +
+{:else if tab === 'ruta'} +
+ + + + + +

Origen

+ + + {#if crmCatalogs.options('puerto').length} + + {:else} + + {/if} + + +

Destino

+ + + {#if crmCatalogs.options('puerto').length} + + {:else} + + {/if} + + + + + +
+{:else if tab === 'mercancia'} +
+ + + + + +
+ + + + +
+
+{:else if tab === 'dimensiones'} +
+ + + + + + + + + + +
+ {#if isFcl} +
+

FCL — Contenedor completo

+ + +
+ {/if} + {#if isLcl} +
+

LCL — Carga consolidada

+ + + + +
+ {/if} +{:else if tab === 'servicios'} +

Servicios adicionales

+
+ {#each crmCatalogs.options('servicio_adicional') as s (s.value)} + + {/each} +
+ +{:else if tab === 'notas'} +
+ + + +
+{/if} diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 2cca268..8095bc9 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -181,7 +181,25 @@ export const SERVICE_TYPES: Option[] = [ export const LOAD_TYPES: Option[] = [ { value: 'FCL', label: 'FCL (contenedor completo)' }, - { value: 'LCL', label: 'LCL (carga consolidada)' } + { value: 'LCL', label: 'LCL (carga consolidada)' }, + { value: 'AMBAS', label: 'Ambas (comparar FCL y LCL)' } +]; + +export const PRIORITIES: Option[] = [ + { value: 'baja', label: 'Baja' }, + { value: 'normal', label: 'Normal' }, + { value: 'alta', label: 'Alta' }, + { value: 'urgente', label: 'Urgente' } +]; + +// Pestañas del formulario de solicitud de servicio (documento maestro de cotización) +export const SR_FORM_TABS: Option[] = [ + { value: 'datos', label: 'Datos' }, + { value: 'ruta', label: 'Servicio y ruta' }, + { value: 'mercancia', label: 'Mercancía' }, + { value: 'dimensiones', label: 'Dimensiones' }, + { value: 'servicios', label: 'Servicios' }, + { value: 'notas', label: 'Notas' } ]; export const SR_STATUS: Option[] = [ diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index 6573670..dd8cd01 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -113,13 +113,24 @@ } } - async function release() { + let showRelease = $state(false); + let releaseDir = $state<'importacion' | 'exportacion'>('exportacion'); + + function openRelease() { + if (!quote) return; + // Prefija la dirección desde la solicitud asociada (si la hay) + const sr = requests.find((r) => r.id === quote?.service_request_id); + releaseDir = (sr?.operation_type as 'importacion' | 'exportacion') ?? 'exportacion'; + showRelease = true; + } + + async function confirmRelease() { if (!companyId || !quote) return; - if (!confirm('¿Liberar esta cotización a Operaciones (crear embarque)?')) return; busy = true; try { - const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId); + const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId, releaseDir); toast.success('Embarque creado'); + showRelease = false; await goto(`/dashboard/ops/embarques/${shipment.id}`); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo liberar'); @@ -222,7 +233,7 @@ {/if} {#if quote.status === 'aceptada'} - + {/if} {#if quote.status === 'rechazada'} @@ -284,7 +295,9 @@ + + {#if quote.load_type}{/if} @@ -312,3 +325,26 @@ {/if} + +{#if showRelease && quote} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte index f9a1433..8a9a1de 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte @@ -7,7 +7,7 @@ import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm'; import { toast } from 'svelte-sonner'; - let form = $state({ currency: 'USD' }); + let form = $state({ currency: 'USD', issue_date: new Date().toISOString().slice(0, 10) }); let accounts = $state([]); let requests = $state([]); let saving = $state(false); @@ -50,6 +50,7 @@ + diff --git a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte index 698378d..06a2211 100644 --- a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte +++ b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte @@ -16,7 +16,7 @@ type Stage, type Account } from '$lib/api/crm'; - import { formatMoney } from '$lib/components/crm/format'; + import { formatMoney, OPERATION_TYPES, TRANSPORT_MODES } from '$lib/components/crm/format'; import { toast } from 'svelte-sonner'; let pipelines = $state([]); @@ -32,6 +32,12 @@ let saving = $state(false); let form = $state({ name: '' }); + // Convertir oportunidad → solicitud (la dirección se hereda de la oportunidad) + let convertOpen = $state(false); + let converting = $state(false); + let convertOpp = $state(null); + let convertForm = $state<{ operation_type: string; transport_mode?: string; incoterm?: string; origin?: string; destination?: string; notes?: string }>({ operation_type: 'exportacion' }); + const companyId = $derived(companyStore.activeCompany?.id ?? null); const currentStages = $derived( @@ -124,7 +130,8 @@ form = { name: '', pipeline_id: selectedPipelineId ?? undefined, - stage_id: currentStages[0]?.id + stage_id: currentStages[0]?.id, + operation_type: 'exportacion' }; modalOpen = true; } @@ -152,17 +159,35 @@ } } - async function convertToRequest(opp: Opportunity) { - if (!companyId) return; - const op = window.prompt('Convertir a solicitud — tipo de operación (importacion / exportacion):', 'exportacion'); - if (!op) return; - const operation_type = op.trim().toLowerCase() === 'importacion' ? 'importacion' : 'exportacion'; + function openConvert(opp: Opportunity) { + convertOpp = opp; + convertForm = { operation_type: opp.operation_type ?? 'exportacion' }; + convertOpen = true; + } + + async function confirmConvert() { + if (!companyId || !convertOpp) return; + converting = true; try { - const sr = await serviceRequestsAPI.fromOpportunity(opp.id, { operation_type }, companyId); + const sr = await serviceRequestsAPI.fromOpportunity( + convertOpp.id, + { + operation_type: convertForm.operation_type, + transport_mode: convertForm.transport_mode || undefined, + incoterm: convertForm.incoterm || undefined, + origin: convertForm.origin || undefined, + destination: convertForm.destination || undefined, + notes: convertForm.notes || undefined + }, + companyId + ); toast.success('Solicitud creada desde la oportunidad'); + convertOpen = false; await goto(`/dashboard/crm/solicitudes/${sr.id}`); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo convertir'); + } finally { + converting = false; } } @@ -264,7 +289,7 @@ {opp.probability}% {/if} - @@ -292,6 +317,13 @@ {#each accounts as a (a.id)}{/each} +
{/if} + +{#if convertOpen && convertOpp} + +{/if} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 5660aaf..0e26e7e 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -1,30 +1,36 @@
@@ -46,33 +49,16 @@

Nueva solicitud de servicio

- -
- Generales - - - - - - -
+ +
+ {#each SR_FORM_TABS as t (t.value)} + + {/each} +
-
- Logística y carga - - - - - - - - - - - -
+ -
+
From 36e98ee97689c333bca30537c4bbd4699bb11f5a Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Tue, 4 Aug 2026 07:00:42 -0600 Subject: [PATCH 34/40] feat(crm): costo estimado por servicio adicional, folios visibles y sidebar por flujo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Solicitud: al marcar un servicio adicional se habilita su costo estimado (columna JSON additional_service_costs). Al cotizar, cada servicio marcado se siembra como concepto de la cotización con ese costo de partida (costo=venta). - Folios visibles: se muestran en la tarjeta de Oportunidad del kanban y se aclara en el formulario que el folio se asigna al guardar (las listas ya lo mostraban). - Sidebar CRM reordenado por flujo comercial (captación → embudo → solicitud → cotización → catálogos de apoyo). - Migración c2d3e4f5a6b7 aditiva y reversible. Suite backend en verde (102). Co-Authored-By: Claude Opus 4.8 --- ...ervice_request_additional_service_costs.py | 33 +++++++++++++++++++ backend/api/v1/modules/crm/quotes/service.py | 18 ++++++++++ .../v1/modules/crm/service_requests/dto.py | 2 ++ .../v1/modules/crm/service_requests/models.py | 2 ++ backend/tests/test_quotes.py | 19 +++++++++++ frontend/src/lib/api/crm/commercial.ts | 1 + .../crm/ServiceRequestFields.svelte | 33 ++++++++++++++++--- .../src/lib/components/sidebar/modules.ts | 7 ++-- .../dashboard/crm/oportunidades/+page.svelte | 3 ++ .../crm/solicitudes/[id]/+page.svelte | 8 ++--- .../crm/solicitudes/nuevo/+page.svelte | 2 +- 11 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py diff --git a/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py b/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py new file mode 100644 index 0000000..724438c --- /dev/null +++ b/backend/alembic/versions/c2d3e4f5a6b7_service_request_additional_service_costs.py @@ -0,0 +1,33 @@ +"""Costo estimado por servicio adicional en la solicitud de servicio + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-08-04 00:00:00.000000 + +Agrega crm.service_requests.additional_service_costs (JSON: {codigo_servicio: costo}) +para capturar el costo estimado de cada servicio adicional marcado; ese costo se +usa como punto de partida al sembrar los conceptos de la cotización. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "c2d3e4f5a6b7" +down_revision: Union[str, None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column( + "service_requests", + sa.Column("additional_service_costs", sa.JSON(), nullable=True), + schema=SCHEMA, + ) + + +def downgrade() -> None: + op.drop_column("service_requests", "additional_service_costs", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index 365308d..7c39673 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -6,6 +6,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from ..accounts.models import Account +from ..catalogs.models import CatalogItem from ..common.folios import next_folio from ..service_requests.models import RateRequest, ServiceRequest from ..suppliers.models import Supplier @@ -150,6 +151,14 @@ def create_quotes_from_service_request( ) .all() ) + # Etiquetas legibles de los servicios adicionales (global + tenant) para los conceptos + service_labels = { + code: label + for code, label in db.query(CatalogItem.code, CatalogItem.label).filter( + CatalogItem.catalog == "servicio_adicional" + ) + } + service_costs = sr.additional_service_costs or {} created: list[Quote] = [] for variant in variants: @@ -178,6 +187,15 @@ def create_quotes_from_service_request( unit_cost=amount, unit_sale=amount, currency=rr.currency, tenant_id=tenant_id, company_id=company_id, )) + # Servicios adicionales marcados en la solicitud → conceptos con su costo estimado + for code in (sr.additional_services or []): + amount = Decimal(str(service_costs.get(code) or 0)) + db.add(QuoteItem( + quote_id=quote.id, concept=code[:60], + description=service_labels.get(code, "Servicio adicional"), + quantity=Decimal(1), unit_cost=amount, unit_sale=amount, + currency=sr.currency, tenant_id=tenant_id, company_id=company_id, + )) db.flush() _recompute_totals(db, quote) created.append(quote) diff --git a/backend/api/v1/modules/crm/service_requests/dto.py b/backend/api/v1/modules/crm/service_requests/dto.py index 7ccb583..a45ddde 100644 --- a/backend/api/v1/modules/crm/service_requests/dto.py +++ b/backend/api/v1/modules/crm/service_requests/dto.py @@ -61,6 +61,7 @@ class ServiceRequestBase(BaseModel): volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) # Servicios adicionales (códigos del catálogo servicio_adicional) y pago additional_services: list[str] | None = None + additional_service_costs: dict[str, float] | None = None # {codigo: costo estimado} payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None @@ -146,6 +147,7 @@ class ServiceRequestUpdate(BaseModel): weight_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) volume_per_pallet: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3) additional_services: list[str] | None = None + additional_service_costs: dict[str, float] | None = None payment_method: str | None = Field(None, max_length=20) destination_agent_id: int | None = None requirements: str | None = None diff --git a/backend/api/v1/modules/crm/service_requests/models.py b/backend/api/v1/modules/crm/service_requests/models.py index e141967..5fbd64e 100644 --- a/backend/api/v1/modules/crm/service_requests/models.py +++ b/backend/api/v1/modules/crm/service_requests/models.py @@ -101,6 +101,8 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin): volume_per_pallet: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True) # Servicios adicionales (lista de códigos del catálogo servicio_adicional) y pago additional_services: Mapped[list | None] = mapped_column(JSON, nullable=True) + # Costo estimado por servicio adicional marcado: {codigo: costo} + additional_service_costs: Mapped[dict | None] = mapped_column(JSON, nullable=True) payment_method: Mapped[str | None] = mapped_column(String(20), nullable=True) # Notas client_notes: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/tests/test_quotes.py b/backend/tests/test_quotes.py index aa265ec..b2ccd9f 100644 --- a/backend/tests/test_quotes.py +++ b/backend/tests/test_quotes.py @@ -106,6 +106,25 @@ def test_quote_from_service_request_ambas_genera_dos(db): assert len(service.get_quote_items(db, q.id, T, C)) == 2 +def test_quote_from_service_request_seeds_additional_services(db): + sr = sr_service.create_service_request( + db, + ServiceRequestCreate( + operation_type="importacion", load_type="FCL", currency="USD", + additional_services=["seguro", "despacho_aduanal"], + additional_service_costs={"seguro": 500, "despacho_aduanal": 300}, + ), + T, C, + ) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + items = service.get_quote_items(db, quotes[0].id, T, C) + costs = {i.concept: float(i.unit_cost) for i in items} + assert costs.get("seguro") == 500.0 + assert costs.get("despacho_aduanal") == 300.0 + # el costo estimado de la solicitud es el punto de partida (costo=venta) + assert float(quotes[0].total_sale) == 800.0 + + def test_quote_from_service_request_sets_issue_date_today(db): sr = _sr_with_rates(db) quotes = service.create_quotes_from_service_request(db, sr.id, T, C) diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index 184c5fb..3afe080 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -59,6 +59,7 @@ export interface ServiceRequest { weight_per_pallet: number | null; volume_per_pallet: number | null; additional_services: string[] | null; + additional_service_costs: Record | null; payment_method: string | null; destination_agent_id: number | null; requirements: string | null; diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte index 6f86a54..7f8d7b2 100644 --- a/frontend/src/lib/components/crm/ServiceRequestFields.svelte +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -34,6 +34,18 @@ return [c.first_name, c.last_name].filter(Boolean).join(' '); } + // Costo estimado por servicio adicional (se traspasa a la cotización) + function serviceCost(code: string): number | undefined { + return form.additional_service_costs?.[code]; + } + function setServiceCost(code: string, value: string) { + const map = { ...(form.additional_service_costs ?? {}) }; + const n = value === '' ? NaN : Number(value); + if (Number.isNaN(n)) delete map[code]; + else map[code] = n; + form.additional_service_costs = map; + } + onMount(() => { void crmCatalogs.preload([ 'pais', 'moneda', 'prioridad', 'tipo_mercancia', 'unidad_medida', @@ -41,12 +53,13 @@ 'puerto', 'aeropuerto' ]); if (!form.additional_services) form.additional_services = []; + if (!form.additional_service_costs) form.additional_service_costs = {}; }); {#if tab === 'datos'}
- + @@ -139,13 +152,23 @@
{/if} {:else if tab === 'servicios'} -

Servicios adicionales

-
+

Servicios adicionales

+

Marca los servicios requeridos e indica su costo estimado (opcional). Al cotizar, cada servicio marcado se agrega como concepto de la cotización con ese costo de partida.

+
{#each crmCatalogs.options('servicio_adicional') as s (s.value)} - + {@const checked = (form.additional_services ?? []).includes(s.value)} +
+ + {#if checked} +
+ setServiceCost(s.value, e.currentTarget.value)} /> + {form.currency ?? ''} +
+ {/if} +
{/each}
- + {:else if tab === 'notas'}
diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 8d91390..b267a95 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -42,17 +42,18 @@ export function getNavMain(): NavMainItem[] { title: 'CRM', url: '/dashboard/crm', icon: Briefcase, + // Orden por flujo comercial: captación → embudo → solicitud → cotización → apoyo items: [ { title: 'Panel', url: '/dashboard/crm' }, { title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' }, - { title: 'Proveedores', url: '/dashboard/crm/proveedores' }, { title: 'Contactos', url: '/dashboard/crm/contactos' }, + { title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' }, + { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, { title: 'Solicitudes', url: '/dashboard/crm/solicitudes' }, { title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' }, { title: 'Tarifarios', url: '/dashboard/crm/tarifarios' }, { title: 'Cotizador', url: '/dashboard/crm/cotizador' }, - { title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' }, - { title: 'Oportunidades', url: '/dashboard/crm/oportunidades' }, + { title: 'Proveedores', url: '/dashboard/crm/proveedores' }, { title: 'Actividades', url: '/dashboard/crm/actividades' }, { title: 'Catálogos', url: '/dashboard/crm/catalogos' }, ], diff --git a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte index 06a2211..c8735db 100644 --- a/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte +++ b/frontend/src/routes/dashboard/crm/oportunidades/+page.svelte @@ -276,6 +276,9 @@ ondragstart={(e) => onDragStart(e, opp.id)} >

{opp.name}

+ {#if opp.reference} +

{opp.reference}

+ {/if} {#if accountName(opp.account_id)}

{accountName(opp.account_id)}

{/if} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 0e26e7e..49befff 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -54,7 +54,7 @@ contactsAPI.list(cid), rateRequestsAPI.list(cid, id) ]); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud'); } finally { @@ -67,7 +67,7 @@ saving = true; try { sr = await serviceRequestsAPI.update(sr.id, form, companyId); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; toast.success('Cambios guardados'); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo guardar'); @@ -82,7 +82,7 @@ busy = true; try { sr = await serviceRequestsAPI.registerContact(sr.id, companyId, notes); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; toast.success('Contacto registrado'); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo registrar el contacto'); @@ -96,7 +96,7 @@ busy = true; try { sr = await serviceRequestsAPI.requote(sr.id, companyId); - form = { ...sr, additional_services: sr.additional_services ?? [] }; + form = { ...sr, additional_services: sr.additional_services ?? [], additional_service_costs: sr.additional_service_costs ?? {} }; toast.success('Solicitud reabierta para re-cotizar'); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo reabrir'); diff --git a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte index 1ddd24f..f5187ea 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte @@ -9,7 +9,7 @@ import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte'; import { toast } from 'svelte-sonner'; - let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [] }); + let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} }); let accounts = $state([]); let suppliers = $state([]); let contacts = $state([]); From f1e6fba75d5ba6eac25f79c677f1f2efa6333e70 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Tue, 4 Aug 2026 07:30:25 -0600 Subject: [PATCH 35/40] =?UTF-8?q?feat(crm):=20cotizaci=C3=B3n=20a=C3=A9rea?= =?UTF-8?q?=20con=20peso/volumen=20(P/Vol)=20operacional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modalidad AÉREO (4ª opción en "¿Cómo desea cotizar?"): en la solicitud muestra la sección aérea con el cálculo en vivo P/Vol = (L×A×H cm × bultos)/6000 y el peso a cobrar = max(peso bruto, P/Vol). Fija el transporte en aéreo. - Utilidad compartida crm/common/pricing.py (air_volumetric_kg / air_chargeable_kg, factor internacional 6000). - Motor de costeo (rates): la rama aérea usa el P/Vol por dimensiones si vienen (CostRequest ahora acepta length/width/height_cm); respaldo m³×167 cuando no. - Cotizador: captura por dimensiones (L×A×H + bultos) en modo aéreo y muestra el P/Vol. - Solicitud→Cotización: si es AÉREO, siembra el concepto de flete con cantidad = peso a cobrar (P/Vol) para capturar la tarifa por kg. - Pruebas: test_pricing (ejemplo del doc → 720; max bruto/volumétrico) + cotización aérea desde solicitud. Suite en verde (108). Co-Authored-By: Claude Opus 4.8 --- backend/api/v1/modules/crm/common/pricing.py | 37 +++++++++++++++++++ backend/api/v1/modules/crm/quotes/service.py | 14 +++++++ backend/api/v1/modules/crm/rates/dto.py | 4 ++ backend/api/v1/modules/crm/rates/service.py | 10 ++++- backend/tests/test_pricing.py | 29 +++++++++++++++ backend/tests/test_quotes.py | 18 +++++++++ frontend/src/lib/api/crm/rates.ts | 5 ++- .../crm/ServiceRequestFields.svelte | 29 ++++++++++++++- frontend/src/lib/components/crm/format.ts | 3 +- .../dashboard/crm/cotizador/+page.svelte | 25 +++++++++++++ 10 files changed, 168 insertions(+), 6 deletions(-) create mode 100644 backend/api/v1/modules/crm/common/pricing.py create mode 100644 backend/tests/test_pricing.py diff --git a/backend/api/v1/modules/crm/common/pricing.py b/backend/api/v1/modules/crm/common/pricing.py new file mode 100644 index 0000000..98d152c --- /dev/null +++ b/backend/api/v1/modules/crm/common/pricing.py @@ -0,0 +1,37 @@ +"""Cálculos de precio compartidos del proceso comercial. + +Peso volumétrico / a cobrar de carga aérea (doc maestro de cotización): + P/Vol = (Largo_cm × Ancho_cm × Alto_cm × cantidad) / 6000 +El peso a cobrar es el mayor entre el peso bruto y el P/Vol (estándar aéreo). +6000 cm³/kg es el factor internacional (equivale a ~167 kg/m³). +""" + +from __future__ import annotations + +from decimal import Decimal + +# Factor internacional de peso volumétrico aéreo (cm³ por kg). +AIR_VOLUMETRIC_DIVISOR = Decimal("6000") + + +def _d(value) -> Decimal: + if value is None: + return Decimal(0) + return value if isinstance(value, Decimal) else Decimal(str(value)) + + +def air_volumetric_kg(length_cm, width_cm, height_cm, qty=1) -> Decimal: + """Peso volumétrico aéreo a partir de dimensiones (cm) y cantidad de bultos. + + Devuelve 0 si falta alguna dimensión (no se puede calcular). + """ + length, width, height = _d(length_cm), _d(width_cm), _d(height_cm) + if length <= 0 or width <= 0 or height <= 0: + return Decimal(0) + quantity = _d(qty) if _d(qty) > 0 else Decimal(1) + return (length * width * height * quantity) / AIR_VOLUMETRIC_DIVISOR + + +def air_chargeable_kg(gross_kg, length_cm, width_cm, height_cm, qty=1) -> Decimal: + """Peso a cobrar aéreo: max(peso bruto, peso volumétrico por dimensiones).""" + return max(_d(gross_kg), air_volumetric_kg(length_cm, width_cm, height_cm, qty)) diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index 7c39673..5afeea2 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from ..accounts.models import Account from ..catalogs.models import CatalogItem from ..common.folios import next_folio +from ..common.pricing import air_chargeable_kg from ..service_requests.models import RateRequest, ServiceRequest from ..suppliers.models import Supplier from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate @@ -196,6 +197,19 @@ def create_quotes_from_service_request( quantity=Decimal(1), unit_cost=amount, unit_sale=amount, currency=sr.currency, tenant_id=tenant_id, company_id=company_id, )) + # Carga aérea: concepto de flete con el peso a cobrar (P/Vol) como cantidad, + # para que el ejecutivo capture la tarifa por kg. + if (variant or "").upper() == "AEREO": + chargeable = air_chargeable_kg( + sr.weight, sr.length_cm, sr.width_cm, sr.height_cm, + sr.pallets_count or sr.pieces_count or 1, + ) + db.add(QuoteItem( + quote_id=quote.id, concept="flete_internacional", + description=f"Flete aéreo — peso a cobrar {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)", + quantity=chargeable, unit_cost=Decimal(0), unit_sale=Decimal(0), + currency=sr.currency, tenant_id=tenant_id, company_id=company_id, + )) db.flush() _recompute_totals(db, quote) created.append(quote) diff --git a/backend/api/v1/modules/crm/rates/dto.py b/backend/api/v1/modules/crm/rates/dto.py index a5aec50..da4a025 100644 --- a/backend/api/v1/modules/crm/rates/dto.py +++ b/backend/api/v1/modules/crm/rates/dto.py @@ -146,6 +146,10 @@ class CostRequest(BaseModel): on_date: date | None = None gross_weight_kg: Decimal | None = None volume_m3: Decimal | None = None + # Dimensiones (cm) para el peso volumétrico aéreo (P/Vol = L×A×H×cant / 6000) + length_cm: Decimal | None = None + width_cm: Decimal | None = None + height_cm: Decimal | None = None equipment_type: str | None = None quantity: int = 1 dangerous: bool = False diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py index 8804e30..aff1881 100644 --- a/backend/api/v1/modules/crm/rates/service.py +++ b/backend/api/v1/modules/crm/rates/service.py @@ -20,9 +20,11 @@ from .dto import ( RateSheetCreate, RateSheetUpdate, ) +from ..common.pricing import air_volumetric_kg from .models import RateBreak, RateCharge, RateLane, RateSheet # Factor volumétrico aéreo: 1 m³ = 167 kg (equivale a 6000 cm³/kg). +# Respaldo cuando solo se conoce el volumen en m³ (sin dimensiones cm). AIR_VOLUMETRIC_FACTOR = Decimal("167") @@ -518,11 +520,15 @@ def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) - base = max(base, lane.min_charge or Decimal(0)) detail = f"W/M {wm.quantize(Decimal('0.01'))}" else: # aereo - chargeable = max(gross, _volumetric_kg(req.volume_m3)) + # P/Vol por dimensiones (L×A×H×cant / 6000); si no hay dimensiones, + # respaldo con el volumen en m³ × 167. + vol_by_dims = air_volumetric_kg(req.length_cm, req.width_cm, req.height_cm, req.quantity) + volumetric = vol_by_dims if vol_by_dims > 0 else _volumetric_kg(req.volume_m3) + chargeable = max(gross, volumetric) brks = breaks_of(db, lane.id) base = _best_break_cost(brks, chargeable) base = max(base, lane.min_charge or Decimal(0)) - detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg" + detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)" charge_lines = _apply_charges(db, sheet, lane, base, chargeable, req.quantity, req.dangerous) total = base + sum((c.amount for c in charge_lines), Decimal(0)) diff --git a/backend/tests/test_pricing.py b/backend/tests/test_pricing.py new file mode 100644 index 0000000..b352715 --- /dev/null +++ b/backend/tests/test_pricing.py @@ -0,0 +1,29 @@ +"""Pruebas del cálculo de peso/volumen (P/Vol) aéreo.""" + +from decimal import Decimal + +from api.v1.modules.crm.common.pricing import air_chargeable_kg, air_volumetric_kg + + +def test_air_volumetric_doc_example(): + # 3 pallets 120×120×100 cm → (120*120*100*3)/6000 = 720 (ejemplo del documento) + assert air_volumetric_kg(120, 120, 100, 3) == Decimal(720) + + +def test_air_volumetric_zero_without_dimensions(): + assert air_volumetric_kg(None, 120, 100, 3) == Decimal(0) + assert air_volumetric_kg(0, 120, 100, 3) == Decimal(0) + + +def test_air_qty_defaults_to_one(): + assert air_volumetric_kg(100, 100, 100, 0) == air_volumetric_kg(100, 100, 100, 1) + + +def test_air_chargeable_takes_gross_when_larger(): + # bruto 800 > volumétrico 720 → se cobra 800 + assert air_chargeable_kg(800, 120, 120, 100, 3) == Decimal(800) + + +def test_air_chargeable_takes_volumetric_when_larger(): + # bruto 200 < volumétrico 720 → se cobra 720 + assert air_chargeable_kg(200, 120, 120, 100, 3) == Decimal(720) diff --git a/backend/tests/test_quotes.py b/backend/tests/test_quotes.py index b2ccd9f..4a08191 100644 --- a/backend/tests/test_quotes.py +++ b/backend/tests/test_quotes.py @@ -125,6 +125,24 @@ def test_quote_from_service_request_seeds_additional_services(db): assert float(quotes[0].total_sale) == 800.0 +def test_quote_from_service_request_aereo_seeds_pvol_concept(db): + sr = sr_service.create_service_request( + db, + ServiceRequestCreate( + operation_type="exportacion", load_type="AEREO", currency="USD", + weight=200, length_cm=120, width_cm=120, height_cm=100, pallets_count=3, + ), + T, C, + ) + quotes = service.create_quotes_from_service_request(db, sr.id, T, C) + assert len(quotes) == 1 + assert quotes[0].load_type == "AEREO" + flete = [i for i in service.get_quote_items(db, quotes[0].id, T, C) if i.concept == "flete_internacional"] + assert len(flete) == 1 + # cantidad del flete = peso a cobrar (P/Vol 720 > bruto 200) + assert float(flete[0].quantity) == 720.0 + + def test_quote_from_service_request_sets_issue_date_today(db): sr = _sr_with_rates(db) quotes = service.create_quotes_from_service_request(db, sr.id, T, C) diff --git a/frontend/src/lib/api/crm/rates.ts b/frontend/src/lib/api/crm/rates.ts index 9c4c8dd..57f878e 100644 --- a/frontend/src/lib/api/crm/rates.ts +++ b/frontend/src/lib/api/crm/rates.ts @@ -35,8 +35,9 @@ export interface ImportPreview { mode: RateMode; total: number; valid: number; r export interface CostRequest { mode: RateMode; origin?: string | null; destination?: string | null; on_date?: string | null; - gross_weight_kg?: number | null; volume_m3?: number | null; equipment_type?: string | null; - quantity?: number; dangerous?: boolean; + gross_weight_kg?: number | null; volume_m3?: number | null; + length_cm?: number | null; width_cm?: number | null; height_cm?: number | null; + equipment_type?: string | null; quantity?: number; dangerous?: boolean; } export interface CostChargeLine { concept: string; amount: number; } export interface CostOption { diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte index 7f8d7b2..d712ac9 100644 --- a/frontend/src/lib/components/crm/ServiceRequestFields.svelte +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -21,9 +21,24 @@ const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring'; - // FCL/LCL condicionales; "AMBAS" muestra ambas secciones + // FCL/LCL condicionales; "AMBAS" muestra ambas secciones; "AEREO" muestra la sección aérea const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS'); const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS'); + const isAir = $derived(form.load_type === 'AEREO'); + + // Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol) + const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1); + const airVolumetric = $derived( + Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0 + ? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000 + : 0 + ); + const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); + + // La modalidad aérea fija el medio de transporte en "aéreo" + $effect(() => { + if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo'; + }); // Contactos del cliente seleccionado (o todos si no hay cliente) const clientContacts = $derived( @@ -151,6 +166,18 @@
{/if} + {#if isAir} +
+

Aéreo — Peso / Volumen (P/Vol)

+

P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.

+
+
Cantidad de bultos{airQty}
+
Peso volumétrico (P/Vol){airVolumetric.toFixed(2)}
+
Peso bruto{(Number(form.weight) || 0).toFixed(2)} kg
+
Peso a cobrar{airChargeable.toFixed(2)} kg
+
+
+ {/if} {:else if tab === 'servicios'}

Servicios adicionales

Marca los servicios requeridos e indica su costo estimado (opcional). Al cotizar, cada servicio marcado se agrega como concepto de la cotización con ese costo de partida.

diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 8095bc9..147e66b 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -182,7 +182,8 @@ export const SERVICE_TYPES: Option[] = [ export const LOAD_TYPES: Option[] = [ { value: 'FCL', label: 'FCL (contenedor completo)' }, { value: 'LCL', label: 'LCL (carga consolidada)' }, - { value: 'AMBAS', label: 'Ambas (comparar FCL y LCL)' } + { value: 'AMBAS', label: 'Ambas (comparar FCL y LCL)' }, + { value: 'AEREO', label: 'Aéreo (carga aérea)' } ]; export const PRIORITIES: Option[] = [ diff --git a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte index 0704dcb..de1f072 100644 --- a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte @@ -16,6 +16,7 @@ let f = $state({ mode: 'aereo' as RateMode, origin: '', destination: '', on_date: '', gross_weight_kg: null as number | null, volume_m3: null as number | null, + length_cm: null as number | null, width_cm: null as number | null, height_cm: null as number | null, equipment_type: '', quantity: 1, dangerous: false }); let options = $state([]); @@ -23,6 +24,15 @@ let working = $state(false); const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre'); + const isAir = $derived(f.mode === 'aereo'); + + // P/Vol aéreo en vivo: (L×A×H cm × cantidad) / 6000; a cobrar = max(bruto, P/Vol) + const airVolumetric = $derived( + Number(f.length_cm) > 0 && Number(f.width_cm) > 0 && Number(f.height_cm) > 0 + ? (Number(f.length_cm) * Number(f.width_cm) * Number(f.height_cm) * (Number(f.quantity) || 1)) / 6000 + : 0 + ); + const airChargeable = $derived(Math.max(Number(f.gross_weight_kg) || 0, airVolumetric)); onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo'])); @@ -34,6 +44,7 @@ const res = await rateSheetsAPI.quote({ mode: f.mode, origin: f.origin || null, destination: f.destination || null, on_date: f.on_date || null, gross_weight_kg: f.gross_weight_kg, volume_m3: f.volume_m3, + length_cm: f.length_cm, width_cm: f.width_cm, height_cm: f.height_cm, equipment_type: f.equipment_type || null, quantity: f.quantity || 1, dangerous: f.dangerous }, companyId); options = res.options; calculated = true; @@ -70,11 +81,25 @@ {:else} + {#if isAir} + + + + + {/if} {/if}
+ {#if isAir && airVolumetric > 0} +
+ P/Vol (volumétrico): {airVolumetric.toFixed(2)} + Peso bruto: {(Number(f.gross_weight_kg) || 0).toFixed(2)} kg + Peso a cobrar: {airChargeable.toFixed(2)} kg + P/Vol = (L×A×H) × bultos ÷ 6000 +
+ {/if}
From 9c46f5bf3ca72d86ddfcb0230ec16132bb959415 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Tue, 4 Aug 2026 08:07:58 -0600 Subject: [PATCH 36/40] =?UTF-8?q?feat(crm):=20ajustes=20de=20la=20sesi?= =?UTF-8?q?=C3=B3n=20doc=202=20(cat=C3=A1logos,=20bugs,=20oportunidades,?= =?UTF-8?q?=20facturaci=C3=B3n,=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catálogos y selects: - Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal de conversión de oportunidad. - Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación. - "Tipo de transporte" desde catálogo medio_transporte (antes lista fija). - Cotizador: origen/destino como selects alineados a las rutas de los tarifarios activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta. Bugs de la sesión: - Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se amplía accounts.country y se normaliza 'MX'→'MEX' (migración). - Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id. - Selects ilegibles en modo oscuro: regla global select option en app.css. - Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados. - RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar). Oportunidades: - Se quitan etapas Prospecto/Contactado del embudo semilla. - Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a Ganada/Perdida (migración). Facturación: - Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección). - Moneda como catálogo. UI: - Giro "otro" habilita campo para especificar (accounts.industry_other, migración). - Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor). - Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips). Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9). Suite backend en verde (109). svelte-check sin errores nuevos. Co-Authored-By: Claude Opus 4.8 --- ...6b7c8_session_fixes_accounts_forma_pago.py | 56 +++++++++++++++ ...e4f5a6b7c8d9_opportunity_won_lost_dates.py | 30 ++++++++ backend/api/v1/modules/crm/accounts/dto.py | 6 +- backend/api/v1/modules/crm/accounts/models.py | 3 +- backend/api/v1/modules/crm/addresses/dto.py | 4 +- .../api/v1/modules/crm/catalogs/seed_data.py | 19 ++++++ backend/api/v1/modules/crm/common/folios.py | 10 ++- .../api/v1/modules/crm/opportunities/dto.py | 4 ++ .../v1/modules/crm/opportunities/models.py | 2 + .../v1/modules/crm/opportunities/service.py | 8 ++- backend/api/v1/modules/crm/rates/routes.py | 12 ++++ backend/api/v1/modules/crm/rates/service.py | 24 +++++++ .../api/v1/modules/fin/invoices/service.py | 4 ++ backend/tests/test_accounts.py | 2 +- backend/tests/test_folios.py | 6 ++ backend/tests/test_opportunities.py | 4 ++ frontend/src/app.css | 8 +++ frontend/src/lib/api/crm/contacts.ts | 3 +- frontend/src/lib/api/crm/rates.ts | 6 +- frontend/src/lib/api/crm/types.ts | 3 + .../lib/components/crm/AccountFields.svelte | 3 + .../lib/components/crm/RelatedManager.svelte | 49 +++++++++---- .../crm/ServiceRequestFields.svelte | 6 +- .../lib/components/crm/SupplierFields.svelte | 43 ++++++++++-- .../dashboard/crm/contactos/+page.svelte | 23 +++++-- .../crm/cotizaciones/[id]/+page.svelte | 4 +- .../crm/cotizaciones/nuevo/+page.svelte | 4 +- .../dashboard/crm/cotizador/+page.svelte | 28 +++++++- .../dashboard/crm/oportunidades/+page.svelte | 68 ++++++++++++++++++- .../dashboard/fin/facturas/nuevo/+page.svelte | 6 +- 30 files changed, 399 insertions(+), 49 deletions(-) create mode 100644 backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py create mode 100644 backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py diff --git a/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py b/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py new file mode 100644 index 0000000..2b9d241 --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_session_fixes_accounts_forma_pago.py @@ -0,0 +1,56 @@ +"""Ajustes de sesión: país ISO-3 en accounts, giro "otro" y formas de pago SAT a 2 dígitos + +Revision ID: d3e4f5a6b7c8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-08-04 01:00:00.000000 + +- crm.accounts.country String(2)→String(3) (ISO alfa-3, alineado a catálogo pais). +- crm.accounts.industry_other (especificar cuando el giro es "otro"). +- Normaliza formas de pago SAT de 1 dígito a 2 (01, 02, …) en el catálogo y en + los valores guardados en accounts/suppliers; y país 'MX'→'MEX'. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, None] = "c2d3e4f5a6b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + # País a ISO alfa-3 en accounts (addresses ya es String(3)). + # Primero se amplía la columna; luego se normaliza el dato (evita truncamiento). + op.alter_column( + "accounts", "country", schema=SCHEMA, + existing_type=sa.String(length=2), type_=sa.String(length=3), + existing_nullable=True, server_default=sa.text("'MEX'"), + ) + op.execute("UPDATE crm.accounts SET country = 'MEX' WHERE country = 'MX'") + op.execute("UPDATE crm.addresses SET country = 'MEX' WHERE country = 'MX'") + # Giro "otro" — campo para especificar + op.add_column("accounts", sa.Column("industry_other", sa.String(length=120), nullable=True), schema=SCHEMA) + + # Formas de pago SAT: 1 dígito → 2 dígitos (catálogo + valores guardados) + op.execute( + "UPDATE crm.catalog_items SET code = lpad(code, 2, '0') " + "WHERE catalog = 'forma_pago' AND char_length(code) = 1" + ) + op.execute("UPDATE crm.accounts SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1") + op.execute("UPDATE crm.suppliers SET payment_form = lpad(payment_form, 2, '0') WHERE char_length(payment_form) = 1") + + +def downgrade() -> None: + op.drop_column("accounts", "industry_other", schema=SCHEMA) + # Regresar país a String(2) sin truncar filas existentes + op.execute("UPDATE crm.accounts SET country = 'MX' WHERE country = 'MEX'") + op.alter_column( + "accounts", "country", schema=SCHEMA, + existing_type=sa.String(length=3), type_=sa.String(length=2), + existing_nullable=True, server_default=sa.text("'MX'"), + ) + # La normalización de formas de pago no se revierte (evita romper códigos multi-dígito). diff --git a/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py b/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py new file mode 100644 index 0000000..d261148 --- /dev/null +++ b/backend/alembic/versions/e4f5a6b7c8d9_opportunity_won_lost_dates.py @@ -0,0 +1,30 @@ +"""Fechas separadas de ganada/perdida en la oportunidad + +Revision ID: e4f5a6b7c8d9 +Revises: d3e4f5a6b7c8 +Create Date: 2026-08-04 02:00:00.000000 + +Agrega crm.opportunities.won_date y lost_date (fechas de cierre separadas, +editables) además de closed_at y lost_reason ya existentes. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "e4f5a6b7c8d9" +down_revision: Union[str, None] = "d3e4f5a6b7c8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column("opportunities", sa.Column("won_date", sa.Date(), nullable=True), schema=SCHEMA) + op.add_column("opportunities", sa.Column("lost_date", sa.Date(), nullable=True), schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column("opportunities", "lost_date", schema=SCHEMA) + op.drop_column("opportunities", "won_date", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py index 4951a14..4708fbe 100644 --- a/backend/api/v1/modules/crm/accounts/dto.py +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -13,6 +13,7 @@ class AccountBase(BaseModel): record_type: str = Field("cliente", max_length=20) # cliente | prospecto person_type: str | None = Field(None, max_length=10) # fisica | moral industry: str | None = Field(None, max_length=120) + industry_other: str | None = Field(None, max_length=120) account_type: str | None = Field(None, max_length=40) status: str = Field("active", max_length=20) # active | inactive # Comercial @@ -38,7 +39,7 @@ class AccountBase(BaseModel): address: str | None = None city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field("MX", max_length=2) + country: str | None = Field("MEX", max_length=3) # Observaciones notes: str | None = None internal_notes: str | None = None @@ -57,6 +58,7 @@ class AccountUpdate(BaseModel): record_type: str | None = Field(None, max_length=20) person_type: str | None = Field(None, max_length=10) industry: str | None = Field(None, max_length=120) + industry_other: str | None = Field(None, max_length=120) account_type: str | None = Field(None, max_length=40) status: str | None = Field(None, max_length=20) commercial_classification: str | None = Field(None, max_length=20) @@ -79,7 +81,7 @@ class AccountUpdate(BaseModel): address: str | None = None city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field(None, max_length=2) + country: str | None = Field(None, max_length=3) notes: str | None = None internal_notes: str | None = None owner_user_id: str | None = Field(None, max_length=64) diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py index 70c8dad..4526b7d 100644 --- a/backend/api/v1/modules/crm/accounts/models.py +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -31,6 +31,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin): # Tipo de persona: fisica | moral person_type: Mapped[str | None] = mapped_column(String(10), nullable=True) industry: Mapped[str | None] = mapped_column(String(120), nullable=True) # giro / industria + industry_other: Mapped[str | None] = mapped_column(String(120), nullable=True) # especificar cuando giro = "otro" # Tipo operativo (immex | agencia_aduanal | importador | exportador | transportista | otro) account_type: Mapped[str | None] = mapped_column(String(40), nullable=True) # Estatus: active | inactive @@ -65,7 +66,7 @@ class Account(Base, TenantScopedMixin, TimestampMixin): address: Mapped[str | None] = mapped_column(Text, nullable=True) city: Mapped[str | None] = mapped_column(String(120), nullable=True) state: Mapped[str | None] = mapped_column(String(120), nullable=True) - country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'")) + country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'")) # ----- Observaciones y auditoría ----- notes: Mapped[str | None] = mapped_column(Text, nullable=True) # comentarios generales diff --git a/backend/api/v1/modules/crm/addresses/dto.py b/backend/api/v1/modules/crm/addresses/dto.py index e04ec29..1a8cf73 100644 --- a/backend/api/v1/modules/crm/addresses/dto.py +++ b/backend/api/v1/modules/crm/addresses/dto.py @@ -14,7 +14,7 @@ class AddressBase(BaseModel): postal_code: str | None = Field(None, max_length=10) city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field("MX", max_length=2) + country: str | None = Field("MEX", max_length=3) # ISO 3166-1 alfa-3 (alineado a catálogo pais) reference_notes: str | None = None is_primary: bool = False @@ -32,7 +32,7 @@ class AddressUpdate(BaseModel): postal_code: str | None = Field(None, max_length=10) city: str | None = Field(None, max_length=120) state: str | None = Field(None, max_length=120) - country: str | None = Field(None, max_length=2) + country: str | None = Field(None, max_length=3) reference_notes: str | None = None is_primary: bool | None = None diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index d2e4f49..30da343 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -847,4 +847,23 @@ GLOBAL_CATALOGS.update({ {'code': 'ficha_tecnica', 'label': 'Ficha técnica'}, {'code': 'carta_instrucciones', 'label': 'Carta de instrucciones'}, {'code': 'otro', 'label': 'Otro'}]}, + 'incoterm': {'label': 'Incoterm (2020)', + 'is_system': True, + 'items': [{'code': 'EXW', 'label': 'EXW — Ex Works (en fábrica)'}, + {'code': 'FCA', 'label': 'FCA — Free Carrier (franco transportista)'}, + {'code': 'FAS', 'label': 'FAS — Free Alongside Ship (franco al costado del buque)'}, + {'code': 'FOB', 'label': 'FOB — Free On Board (franco a bordo)'}, + {'code': 'CFR', 'label': 'CFR — Cost and Freight (costo y flete)'}, + {'code': 'CIF', 'label': 'CIF — Cost, Insurance and Freight (costo, seguro y flete)'}, + {'code': 'CPT', 'label': 'CPT — Carriage Paid To (transporte pagado hasta)'}, + {'code': 'CIP', 'label': 'CIP — Carriage and Insurance Paid To (transporte y seguro pagados hasta)'}, + {'code': 'DAP', 'label': 'DAP — Delivered At Place (entregado en lugar)'}, + {'code': 'DPU', 'label': 'DPU — Delivered At Place Unloaded (entregado en lugar descargado)'}, + {'code': 'DDP', 'label': 'DDP — Delivered Duty Paid (entregado con derechos pagados)'}]}, }) + +# Formas de pago SAT de un dígito → dos dígitos (01, 02, 03, 04, 05, 06, 08). +# El SAT exige dos posiciones; se corrige el catálogo base. +for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []): + if len(_fp['code']) == 1: + _fp['code'] = _fp['code'].zfill(2) diff --git a/backend/api/v1/modules/crm/common/folios.py b/backend/api/v1/modules/crm/common/folios.py index 281b058..55ce31a 100644 --- a/backend/api/v1/modules/crm/common/folios.py +++ b/backend/api/v1/modules/crm/common/folios.py @@ -21,8 +21,8 @@ from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin from core.database import Base -# Entidades válidas y su letra de folio. -ENTITIES = ("O", "S", "C", "OP") +# Entidades válidas y su letra de folio (F = factura, sin dirección impo/expo). +ENTITIES = ("O", "S", "C", "OP", "F") # Mapa dirección de operación → sufijo del folio. _DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"} @@ -56,11 +56,13 @@ def next_folio( entity: str, direction: str | None, on_date: date | None = None, + with_direction: bool = True, ) -> str: """Genera el siguiente folio de una entidad, incrementando su consecutivo mensual. Reserva el número dentro de la transacción activa (no hace commit): el ``create_*`` - que lo invoca es quien confirma junto con la fila recién creada. + que lo invoca es quien confirma junto con la fila recién creada. ``with_direction=False`` + omite el sufijo I/E (p. ej. facturas → ``F2026-08-001``). """ if entity not in ENTITIES: raise ValueError(f"Entidad de folio inválida: {entity!r}") @@ -89,4 +91,6 @@ def next_folio( db.flush() sequence = f"{counter.last_number:03d}" + if not with_direction: + return f"{entity}{period}-{sequence}" return f"{entity}{period}-{sequence}-{direction_suffix(direction)}" diff --git a/backend/api/v1/modules/crm/opportunities/dto.py b/backend/api/v1/modules/crm/opportunities/dto.py index 48a533d..d37df49 100644 --- a/backend/api/v1/modules/crm/opportunities/dto.py +++ b/backend/api/v1/modules/crm/opportunities/dto.py @@ -31,6 +31,8 @@ class OpportunityUpdate(BaseModel): probability: int | None = Field(None, ge=0, le=100) status: str | None = Field(None, max_length=20) expected_close_date: date | None = None + won_date: date | None = None + lost_date: date | None = None lost_reason: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) owner_user_id: str | None = Field(None, max_length=64) @@ -59,6 +61,8 @@ class OpportunityResponse(BaseModel): status: str expected_close_date: date | None closed_at: datetime | None + won_date: date | None = None + lost_date: date | None = None lost_reason: str | None source: str | None owner_user_id: str | None diff --git a/backend/api/v1/modules/crm/opportunities/models.py b/backend/api/v1/modules/crm/opportunities/models.py index 2686511..05b5b62 100644 --- a/backend/api/v1/modules/crm/opportunities/models.py +++ b/backend/api/v1/modules/crm/opportunities/models.py @@ -34,6 +34,8 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin): status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True) expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True) closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + won_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se ganó + lost_date: Mapped[date | None] = mapped_column(Date, nullable=True) # fecha en que se perdió lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) source: Mapped[str | None] = mapped_column(String(60), nullable=True) owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) diff --git a/backend/api/v1/modules/crm/opportunities/service.py b/backend/api/v1/modules/crm/opportunities/service.py index 39ff209..975b443 100644 --- a/backend/api/v1/modules/crm/opportunities/service.py +++ b/backend/api/v1/modules/crm/opportunities/service.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import date, datetime, timezone from fastapi import HTTPException, status from sqlalchemy.orm import Session @@ -47,14 +47,20 @@ def _apply_stage_state(opportunity: Opportunity, stage: PipelineStage) -> None: opportunity.status = "won" opportunity.probability = 100 opportunity.closed_at = datetime.now(timezone.utc) + opportunity.won_date = opportunity.won_date or date.today() + opportunity.lost_date = None elif stage.is_lost: opportunity.status = "lost" opportunity.probability = 0 opportunity.closed_at = datetime.now(timezone.utc) + opportunity.lost_date = opportunity.lost_date or date.today() + opportunity.won_date = None else: opportunity.status = "open" opportunity.probability = stage.probability opportunity.closed_at = None + opportunity.won_date = None + opportunity.lost_date = None def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None: diff --git a/backend/api/v1/modules/crm/rates/routes.py b/backend/api/v1/modules/crm/rates/routes.py index 28d46e7..38f2803 100644 --- a/backend/api/v1/modules/crm/rates/routes.py +++ b/backend/api/v1/modules/crm/rates/routes.py @@ -255,3 +255,15 @@ def rate_quote( tenant_id, _ = _ctx(current_user) options = service.quote_cost(db, tenant_id, company_id, req) return CostResult(request=req, options=options) + + +@cost_router.get("/rate-locations") +def rate_locations( + mode: str = Query(...), + company_id: int = Query(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador.""" + tenant_id, _ = _ctx(current_user) + return service.lane_locations(db, tenant_id, company_id, mode) diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py index aff1881..c6d103f 100644 --- a/backend/api/v1/modules/crm/rates/service.py +++ b/backend/api/v1/modules/crm/rates/service.py @@ -479,6 +479,30 @@ def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal, return lines +def lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]: + """Orígenes/destinos existentes en los tarifarios activos de un modo. + + Alinea el cotizador con las rutas realmente cotizables (los códigos provienen + de las lanes, por lo que el costeo siempre encontrará ruta). + """ + sheets = _sheet_query(db, tenant_id, company_id).filter( + RateSheet.mode == mode, RateSheet.status == "activo", + ).all() + origins: set[str] = set() + destinations: set[str] = set() + for sheet in sheets: + lanes = db.query(RateLane).filter( + RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None), + ).all() + for lane in lanes: + origin = lane.origin or sheet.default_origin + if origin: + origins.add(origin) + if lane.destination: + destinations.add(lane.destination) + return {"origins": sorted(origins), "destinations": sorted(destinations)} + + def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]: on_date = req.on_date or date.today() sheets = _sheet_query(db, tenant_id, company_id).filter( diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index 6820515..907653a 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -6,6 +6,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from api.v1.modules.crm.accounts.models import Account +from api.v1.modules.crm.common.folios import next_folio from api.v1.modules.crm.quotes.models import Quote, QuoteItem from api.v1.modules.ops.shipments.models import Shipment @@ -95,6 +96,9 @@ def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=No data = payload.model_dump() _validate_refs(db, data, tenant_id, company_id) obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) + # Folio F... auto-generado (mensual) si no viene uno explícito + if not obj.reference: + obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False) db.add(obj) db.flush() _recompute(db, obj) diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py index d91fc91..7de05b3 100644 --- a/backend/tests/test_accounts.py +++ b/backend/tests/test_accounts.py @@ -15,7 +15,7 @@ def test_create_and_get_account(db): ) assert acc.id is not None assert acc.status == "active" - assert acc.country == "MX" + assert acc.country == "MEX" # ISO 3166-1 alfa-3 (alineado al catálogo pais) got = service.get_account(db, acc.id, T, C) assert got.name == "Importadora Demo" assert got.rfc == "XAXX010101000" diff --git a/backend/tests/test_folios.py b/backend/tests/test_folios.py index 430659c..406f954 100644 --- a/backend/tests/test_folios.py +++ b/backend/tests/test_folios.py @@ -38,6 +38,12 @@ def test_folio_entities_do_not_share_counter(db): assert op == "OP2025-08-001-I" +def test_folio_invoice_without_direction(db): + # Facturas: entidad F sin sufijo de dirección (F2025-08-001) + folio = next_folio(db, T, C, "F", None, on_date=date(2025, 8, 3), with_direction=False) + assert folio == "F2025-08-001" + + def test_folio_unique_across_many(db): folios = {next_folio(db, T, C, "C", "importacion", on_date=date(2025, 8, 10)) for _ in range(25)} assert len(folios) == 25 # sin duplicados diff --git a/backend/tests/test_opportunities.py b/backend/tests/test_opportunities.py index 4dfa519..de8f320 100644 --- a/backend/tests/test_opportunities.py +++ b/backend/tests/test_opportunities.py @@ -41,6 +41,8 @@ def test_move_to_won_closes_and_sets_probability(db): assert moved.status == "won" assert moved.probability == 100 assert moved.closed_at is not None + assert moved.won_date is not None # fecha de ganada + assert moved.lost_date is None assert moved.stage_id == s_won.id @@ -51,6 +53,8 @@ def test_move_to_lost(db): assert moved.status == "lost" assert moved.probability == 0 assert moved.closed_at is not None + assert moved.lost_date is not None # fecha de perdida + assert moved.won_date is None def test_move_back_to_open_reopens(db): diff --git a/frontend/src/app.css b/frontend/src/app.css index 990d5f9..5b58f9d 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -143,6 +143,14 @@ filter: invert(1) brightness(1.15); opacity: 0.9; } + + /* Las opciones de los {#each crmCatalogs.options('tipo_registro') as r (r.value)}{/each} + {#if form.industry === 'otro'} + + {/if}
diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index 15ce0a5..fb0866e 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -1,5 +1,5 @@ +{#snippet catCsv(labelText: string, catalog: string, value: string, set: (v: string) => void, placeholder: string)} + +{/snippet} + {#if tab === 'generales'}
@@ -56,10 +89,10 @@
- - - - + {@render catCsv('Países donde opera', 'pais', countriesStr, (v) => (countriesStr = v), 'México, Estados Unidos')} + {@render catCsv('Puertos donde opera', 'puerto', portsStr, (v) => (portsStr = v), 'Veracruz, Manzanillo')} + {@render catCsv('Aeropuertos donde opera', 'aeropuerto', airportsStr, (v) => (airportsStr = v), 'MEX, GDL')} + {@render catCsv('Aduanas donde opera', 'aduana', customsStr, (v) => (customsStr = v), 'Nuevo Laredo, Colombia')} diff --git a/frontend/src/routes/dashboard/crm/contactos/+page.svelte b/frontend/src/routes/dashboard/crm/contactos/+page.svelte index 41aff07..5c0f37c 100644 --- a/frontend/src/routes/dashboard/crm/contactos/+page.svelte +++ b/frontend/src/routes/dashboard/crm/contactos/+page.svelte @@ -4,11 +4,12 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; - import { contactsAPI, accountsAPI, type Contact, type ContactInput, type Account } from '$lib/api/crm'; + import { contactsAPI, accountsAPI, suppliersAPI, type Contact, type ContactInput, type Account, type Supplier } from '$lib/api/crm'; import { toast } from 'svelte-sonner'; let items = $state([]); let accounts = $state([]); + let suppliers = $state([]); let loading = $state(false); let search = $state(''); let modalOpen = $state(false); @@ -18,8 +19,18 @@ const companyId = $derived(companyStore.activeCompany?.id ?? null); - function accountName(id: number | null): string { - return accounts.find((a) => a.id === id)?.name ?? '—'; + // A quién pertenece el contacto: cliente/prospecto (cuenta) o proveedor + function ownerLabel(c: Contact): { kind: string; name: string } | null { + if (c.account_id) { + const a = accounts.find((x) => x.id === c.account_id); + const kind = a?.record_type === 'prospecto' ? 'Prospecto' : 'Cliente'; + return { kind, name: a?.name ?? `#${c.account_id}` }; + } + if (c.supplier_id) { + const s = suppliers.find((x) => x.id === c.supplier_id); + return { kind: 'Proveedor', name: s?.name ?? `#${c.supplier_id}` }; + } + return null; } const filtered = $derived( @@ -41,7 +52,7 @@ async function load(cid: number) { loading = true; try { - [items, accounts] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid)]); + [items, accounts, suppliers] = await Promise.all([contactsAPI.list(cid), accountsAPI.list(cid), suppliersAPI.list(cid)]); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los contactos'); } finally { @@ -136,7 +147,7 @@ Nombre - Cuenta + Pertenece a Puesto Email Teléfono @@ -150,7 +161,7 @@ {c.first_name} {c.last_name ?? ''} {#if c.is_primary}Principal{/if} - {accountName(c.account_id)} + {#if ownerLabel(c)}{@const o = ownerLabel(c)}{o?.kind} {o?.name}{:else}—{/if} {c.job_title ?? '—'} {c.email ?? '—'} {c.phone ?? c.mobile ?? '—'} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index dd8cd01..9b227b8 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -12,6 +12,7 @@ } from '$lib/api/crm'; import { shipmentsAPI } from '$lib/api/ops'; import { QUOTE_STATUS, QUOTE_CONCEPTS, labelOf, formatMoney } from '$lib/components/crm/format'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; const quoteId = $derived(Number(page.params.id)); @@ -39,6 +40,7 @@ async function load(cid: number, id: number) { loading = true; + void crmCatalogs.preload(['moneda']); try { [quote, items, accounts, requests, suppliers] = await Promise.all([ quotesAPI.get(id, cid), @@ -294,7 +296,7 @@ - + {#if quote.load_type}{/if} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte index 8a9a1de..ec5a726 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/nuevo/+page.svelte @@ -5,6 +5,7 @@ import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; let form = $state({ currency: 'USD', issue_date: new Date().toISOString().slice(0, 10) }); @@ -17,6 +18,7 @@ $effect(() => { const cid = companyId; if (!cid) return; + void crmCatalogs.preload(['moneda']); void (async () => { [accounts, requests] = await Promise.all([accountsAPI.list(cid), serviceRequestsAPI.list(cid)]); })(); @@ -49,7 +51,7 @@ - + diff --git a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte index de1f072..a2982be 100644 --- a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte @@ -22,6 +22,18 @@ let options = $state([]); let calculated = $state(false); let working = $state(false); + // Orígenes/destinos alineados a las rutas de los tarifarios activos del modo + let locs = $state<{ origins: string[]; destinations: string[] }>({ origins: [], destinations: [] }); + + $effect(() => { + const cid = companyId; + const mode = f.mode; + if (!cid) return; + void (async () => { + try { locs = await rateSheetsAPI.locations(cid, mode); } + catch { locs = { origins: [], destinations: [] }; } + })(); + }); const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre'); const isAir = $derived(f.mode === 'aereo'); @@ -70,8 +82,20 @@ - - + + {#if isFcl} - +
@@ -384,3 +421,28 @@
{/if} + +{#if closeOpen && closeCtx} + +{/if} diff --git a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte index b4c74ae..ce567a7 100644 --- a/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/fin/facturas/nuevo/+page.svelte @@ -6,6 +6,7 @@ import { companyStore } from '$lib/stores/company.svelte'; import { invoicesAPI, type InvoiceInput } from '$lib/api/fin'; import { accountsAPI, type Account } from '$lib/api/crm'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; let form = $state({ currency: 'MXN', tax_rate: 16 }); @@ -16,6 +17,7 @@ $effect(() => { const cid = companyId; if (!cid) return; + void crmCatalogs.preload(['moneda']); void (async () => { accounts = await accountsAPI.list(cid); })(); }); @@ -44,9 +46,9 @@
- + - + From 8c7aeef1a61293f1a1f3c80a94859777b82a582b Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 7 Aug 2026 07:25:20 -0600 Subject: [PATCH 37/40] feat(crm): refinamientos de Solicitud (Fase A del PDF 07-ago) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fecha de solicitud automática (hoy) editable al crear. - Modalidad de carga dependiente del transporte: marítimo→FCL/LCL/Ambas, aéreo→Aérea (autoselección), terrestre→FTL/LTL, ferroviario/multimodal sin modalidad. - Ciudad y Puerto/Aeropuerto dependientes del país (catálogos por parent_code) con respaldo de texto; el campo Puerto/Aeropuerto une ambos catálogos. - Agente en destino filtrado a proveedores clasificados corresponsal/aduanal. - Volumen SIEMPRE en m³ (conversión desde dimensiones según unidad de medida). - P/Vol aéreo etiquetado con unidad (kg) y honra la unidad de medida. - Moneda visible junto al valor de la mercancía. - Lista de solicitudes con columna Cliente + filtro por cliente + búsqueda por nombre. - Formulario de tarifa: campo "Válida hasta" (calendario). - Catálogos globales ciudad/puerto/aeropuerto por país (seed_locations, extensible). - Nuevos load types FTL/LTL. Suite backend en verde (109). Co-Authored-By: Claude Opus 4.8 --- .../api/v1/modules/crm/catalogs/seed_data.py | 5 + .../v1/modules/crm/catalogs/seed_locations.py | 79 ++++++++++++ .../crm/ServiceRequestFields.svelte | 116 ++++++++++++++---- .../dashboard/crm/solicitudes/+page.svelte | 18 ++- .../crm/solicitudes/[id]/+page.svelte | 1 + .../crm/solicitudes/nuevo/+page.svelte | 2 +- 6 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 backend/api/v1/modules/crm/catalogs/seed_locations.py diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index 30da343..c857f03 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -867,3 +867,8 @@ GLOBAL_CATALOGS.update({ for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []): if len(_fp['code']) == 1: _fp['code'] = _fp['code'].zfill(2) + +# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`. +from .seed_locations import LOCATION_CATALOGS # noqa: E402 + +GLOBAL_CATALOGS.update(LOCATION_CATALOGS) diff --git a/backend/api/v1/modules/crm/catalogs/seed_locations.py b/backend/api/v1/modules/crm/catalogs/seed_locations.py new file mode 100644 index 0000000..759f4a7 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed_locations.py @@ -0,0 +1,79 @@ +"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA). + +Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=`). Curado a las +rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios). +Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario +para que el Cotizador encuentre ruta. +""" + +# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)]) +_LOC = [ + ("MEX", + [("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"), + ("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")], + [("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"), + ("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")], + [("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"), + ("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]), + ("USA", + [("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"), + ("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")], + [("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"), + ("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")], + [("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"), + ("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]), + ("CHN", + [("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"), + ("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")], + [("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"), + ("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")], + [("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"), + ("SZX", "Shenzhen"), ("HKG", "Hong Kong")]), + ("DEU", + [("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")], + [("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")], + [("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]), + ("ESP", + [("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")], + [("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")], + [("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]), + ("NLD", + [("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")], + [("NLRTM", "Róterdam")], + [("AMS", "Ámsterdam Schiphol")]), + ("BRA", + [("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")], + [("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")], + [("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]), + ("CAN", + [("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")], + [("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")], + [("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]), + ("JPN", + [("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")], + [("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")], + [("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]), + ("KOR", + [("KR-SEL", "Seúl"), ("KR-PUS", "Busan")], + [("KRPUS", "Busan"), ("KRINC", "Incheon")], + [("ICN", "Seúl Incheon")]), +] + + +def _build() -> dict: + ciudad, puerto, aeropuerto = [], [], [] + for iso3, cities, ports, airports in _LOC: + for code, label in cities: + ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3}) + for code, label in ports: + puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3}) + for code, label in airports: + aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3}) + return { + "ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad}, + "puerto": {"label": "Puerto", "is_system": True, "items": puerto}, + "aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto}, + } + + +LOCATION_CATALOGS = _build() diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte index f5177ed..41c24c8 100644 --- a/frontend/src/lib/components/crm/ServiceRequestFields.svelte +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -21,25 +21,85 @@ const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring'; - // FCL/LCL condicionales; "AMBAS" muestra ambas secciones; "AEREO" muestra la sección aérea - const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS'); - const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS'); - const isAir = $derived(form.load_type === 'AEREO'); - - // Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol) - const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1); - const airVolumetric = $derived( - Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0 - ? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000 - : 0 + // Modalidad de carga según el tipo de transporte (solo se habilita lo que corresponde) + const MODALIDAD_BY_TRANSPORT: Record = { + maritimo: ['FCL', 'LCL', 'AMBAS'], + aereo: ['AEREO'], + terrestre: ['FTL', 'LTL'] + // ferroviario / multimodal: sin modalidad + }; + const modalidadOptions = $derived( + LOAD_TYPES.filter((l) => (MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []).includes(l.value)) ); - const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); + const showModalidad = $derived(modalidadOptions.length > 0); + // Reglas: al cambiar el transporte, la modalidad inválida se limpia; si solo hay una (aéreo), se autoselecciona + $effect(() => { + const allowed = MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []; + if (allowed.length === 0) { + if (form.load_type) form.load_type = undefined; + return; + } + if (form.load_type && !allowed.includes(form.load_type)) form.load_type = undefined; + if (!form.load_type && allowed.length === 1) form.load_type = allowed[0]; + }); // La modalidad aérea fija el medio de transporte en "aéreo" $effect(() => { if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo'; }); + const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS'); + const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS'); + const isAir = $derived(form.load_type === 'AEREO'); + + // Conversión de dimensiones a cm según la unidad de medida (para volumen m³ y P/Vol) + const UNIT_TO_CM: Record = { cm: 1, m: 100, in: 2.54, ft: 30.48 }; + const unitCm = $derived(UNIT_TO_CM[form.measurement_unit ?? 'cm'] ?? 1); + const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1); + const dimL = $derived((Number(form.length_cm) || 0) * unitCm); + const dimW = $derived((Number(form.width_cm) || 0) * unitCm); + const dimH = $derived((Number(form.height_cm) || 0) * unitCm); + const hasDims = $derived(dimL > 0 && dimW > 0 && dimH > 0); + // Volumen SIEMPRE en m³ (cm³ / 1,000,000) + const volumeM3 = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 1_000_000 : 0); + // P/Vol aéreo (kg) = (L×A×H cm × bultos) / 6000; a cobrar = max(bruto, P/Vol) + const airVolumetric = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 6000 : 0); + const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); + + // Autocompletar el volumen en m³ a partir de las dimensiones/unidad + $effect(() => { + if (hasDims) form.volume = Math.round(volumeM3 * 1000) / 1000; + }); + + // Ciudad y puerto/aeropuerto dependen del país (catálogos dependientes, como estado←país) + $effect(() => { + if (form.origin_country) { + void crmCatalogs.ensure('ciudad', form.origin_country); + void crmCatalogs.ensure('puerto', form.origin_country); + void crmCatalogs.ensure('aeropuerto', form.origin_country); + } + }); + $effect(() => { + if (form.destination_country) { + void crmCatalogs.ensure('ciudad', form.destination_country); + void crmCatalogs.ensure('puerto', form.destination_country); + void crmCatalogs.ensure('aeropuerto', form.destination_country); + } + }); + // Campo "Puerto/Aeropuerto": une puertos + aeropuertos del país + function portOptions(country: string | null | undefined) { + return [ + ...crmCatalogs.options('puerto', country ?? undefined), + ...crmCatalogs.options('aeropuerto', country ?? undefined) + ]; + } + + // Agente en destino: solo proveedores clasificados como corresponsal/aduanal (fallback: todos) + const destinationAgents = $derived( + suppliers.filter((s) => (s.classifications ?? []).some((c) => c === 'agente_corresponsal' || c === 'agente_aduanal')) + ); + const agentList = $derived(destinationAgents.length ? destinationAgents : suppliers); + // Contactos del cliente seleccionado (o todos si no hay cliente) const clientContacts = $derived( form.account_id ? contacts.filter((c) => c.account_id === form.account_id) : contacts @@ -91,13 +151,19 @@ - + {#if showModalidad} + + {/if}

Origen

- - {#if crmCatalogs.options('puerto').length} - + {#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.origin_country).length} + {:else} {/if} @@ -105,9 +171,13 @@

Destino

- - {#if crmCatalogs.options('puerto').length} - + {#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.destination_country).length} + {:else} {/if} @@ -115,14 +185,14 @@ - +
{:else if tab === 'mercancia'}
- +
@@ -172,9 +242,9 @@

P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.

Cantidad de bultos{airQty}
-
Peso volumétrico (P/Vol){airVolumetric.toFixed(2)}
+
Peso volumétrico (P/Vol){airVolumetric.toFixed(2)} kg
Peso bruto{(Number(form.weight) || 0).toFixed(2)} kg
-
Peso a cobrar{airChargeable.toFixed(2)} kg
+
Peso a cobrar (P/Vol){airChargeable.toFixed(2)} kg
{/if} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte index 5eac753..63bfe54 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte @@ -4,22 +4,28 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; - import { serviceRequestsAPI, type ServiceRequest } from '$lib/api/crm'; + import { serviceRequestsAPI, accountsAPI, type ServiceRequest, type Account } from '$lib/api/crm'; import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format'; import { toast } from 'svelte-sonner'; let items = $state([]); + let accounts = $state([]); let loading = $state(false); let search = $state(''); let statusFilter = $state(''); + let clientFilter = $state(''); const companyId = $derived(companyStore.activeCompany?.id ?? null); + function accountName(id: number | null): string { + return accounts.find((a) => a.id === id)?.name ?? '—'; + } const filtered = $derived( items.filter((r) => { if (statusFilter && r.status !== statusFilter) return false; + if (clientFilter && String(r.account_id ?? '') !== clientFilter) return false; if (search.trim()) { const q = search.trim().toLowerCase(); - return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''}`.toLowerCase().includes(q); + return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''} ${accountName(r.account_id)}`.toLowerCase().includes(q); } return true; }) @@ -34,7 +40,7 @@ async function load(cid: number) { loading = true; try { - items = await serviceRequestsAPI.list(cid); + [items, accounts] = await Promise.all([serviceRequestsAPI.list(cid), accountsAPI.list(cid)]); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes'); } finally { @@ -76,6 +82,10 @@ {#each SR_STATUS as s (s.value)}{/each} +
@@ -89,6 +99,7 @@ Folio + Cliente Operación Medio Ruta @@ -100,6 +111,7 @@ {#each filtered as r (r.id)} {r.reference ?? `#${r.id}`} + {accountName(r.account_id)} {labelOf(OPERATION_TYPES, r.operation_type)} {labelOf(TRANSPORT_MODES, r.transport_mode)} {[r.origin, r.destination].filter(Boolean).join(' → ') || '—'} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 49befff..07097d9 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -186,6 +186,7 @@ +
diff --git a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte index f5187ea..1c4be37 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte @@ -9,7 +9,7 @@ import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte'; import { toast } from 'svelte-sonner'; - let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} }); + let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {}, request_date: new Date().toISOString().slice(0, 10) }); let accounts = $state([]); let suppliers = $state([]); let contacts = $state([]); From 8431132b10084544ac525e4dad0946a58d91bd87 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 7 Aug 2026 07:37:30 -0600 Subject: [PATCH 38/40] =?UTF-8?q?feat(crm):=20Prospecto=20=E2=80=94=20medi?= =?UTF-8?q?o=20de=20contacto=20preferido=20+=20fix=20visualizaci=C3=B3n=20?= =?UTF-8?q?de=20documentos=20(Fase=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido" (catálogo medio_contacto). Backend leads.preferred_contact_method + migración f0a1b2c3d4e5 reversible. - Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al host interno minio:9000. RelatedManager.openDoc usa blob→objectURL. Suite backend en verde (109). svelte-check sin errores nuevos. Co-Authored-By: Claude Opus 4.8 --- ...1b2c3d4e5_lead_preferred_contact_method.py | 25 ++++++++++++++++ backend/api/v1/modules/crm/leads/dto.py | 3 ++ backend/api/v1/modules/crm/leads/models.py | 2 ++ backend/api/v1/modules/crm/uploads/routes.py | 30 +++++++++++++++++-- frontend/src/lib/api/crm/types.ts | 1 + frontend/src/lib/api/uploads.ts | 7 +++++ .../lib/components/crm/RelatedManager.svelte | 16 +++++++--- .../dashboard/crm/prospectos/+page.svelte | 11 +++++++ 8 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py diff --git a/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py new file mode 100644 index 0000000..6504d2a --- /dev/null +++ b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py @@ -0,0 +1,25 @@ +"""Medio de contacto preferido en el prospecto (lead) + +Revision ID: f0a1b2c3d4e5 +Revises: e4f5a6b7c8d9 +Create Date: 2026-08-07 01:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "f0a1b2c3d4e5" +down_revision: Union[str, None] = "e4f5a6b7c8d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column("leads", "preferred_contact_method", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/leads/dto.py b/backend/api/v1/modules/crm/leads/dto.py index 904dd47..6173b46 100644 --- a/backend/api/v1/modules/crm/leads/dto.py +++ b/backend/api/v1/modules/crm/leads/dto.py @@ -11,6 +11,7 @@ class LeadCreate(BaseModel): phone: str | None = Field(None, max_length=40) company_name: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) + preferred_contact_method: str | None = Field(None, max_length=20) status: str = Field("new", max_length=20) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) owner_user_id: str | None = Field(None, max_length=64) @@ -24,6 +25,7 @@ class LeadUpdate(BaseModel): phone: str | None = Field(None, max_length=40) company_name: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) + preferred_contact_method: str | None = Field(None, max_length=20) status: str | None = Field(None, max_length=20) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) owner_user_id: str | None = Field(None, max_length=64) @@ -50,6 +52,7 @@ class LeadResponse(BaseModel): phone: str | None company_name: str | None source: str | None + preferred_contact_method: str | None = None status: str estimated_value: Decimal | None owner_user_id: str | None diff --git a/backend/api/v1/modules/crm/leads/models.py b/backend/api/v1/modules/crm/leads/models.py index a1e4140..74f20a4 100644 --- a/backend/api/v1/modules/crm/leads/models.py +++ b/backend/api/v1/modules/crm/leads/models.py @@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin): company_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # Origen: web | referido | evento | llamada | email | otro source: Mapped[str | None] = mapped_column(String(60), nullable=True) + # Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|… + preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True) # Estado: new | contacted | qualified | unqualified | converted status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True) estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) diff --git a/backend/api/v1/modules/crm/uploads/routes.py b/backend/api/v1/modules/crm/uploads/routes.py index 63757dd..7968a27 100644 --- a/backend/api/v1/modules/crm/uploads/routes.py +++ b/backend/api/v1/modules/crm/uploads/routes.py @@ -7,10 +7,10 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran). import re import uuid -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from core.security import get_current_user -from core.storage_s3 import presigned_get_url, put_object_bytes +from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes router = APIRouter() @@ -61,3 +61,29 @@ def get_upload_url( if not key.startswith(prefix): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") return {"url": presigned_get_url(key)} + + +@router.get("/uploads/download") +def download_file( + key: str = Query(..., description="Object key del archivo en el almacén"), + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), +): + """Transmite el archivo por el backend (sin exponer MinIO al navegador). + + Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``. + """ + tenant_id = current_user["tenant_id"] + prefix = f"tenants/{tenant_id}/companies/{company_id}/" + if not key.startswith(prefix): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") + try: + data = get_object_bytes(key) + except Exception: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado") + filename = key.rsplit("/", 1)[-1] + return Response( + content=data, + media_type="application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index ca0e2a1..f20f47a 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -192,6 +192,7 @@ export interface Lead { phone: string | null; company_name: string | null; source: string | null; + preferred_contact_method: string | null; status: LeadStatus; estimated_value: number | null; owner_user_id: string | null; diff --git a/frontend/src/lib/api/uploads.ts b/frontend/src/lib/api/uploads.ts index 31dcf25..9a399fc 100644 --- a/frontend/src/lib/api/uploads.ts +++ b/frontend/src/lib/api/uploads.ts @@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise { + return (api as any).getBlob( + `/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}` + ) as Promise; +} diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index fb0866e..3900dd2 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -11,7 +11,7 @@ import { DOC_TYPES, labelOf } from '$lib/components/crm/format'; import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { onMount } from 'svelte'; - import { uploadFile, uploadUrl } from '$lib/api/uploads'; + import { uploadFile, downloadBlob } from '$lib/api/uploads'; import { toast } from 'svelte-sonner'; onMount(() => { @@ -62,9 +62,17 @@ async function openDoc(d: Document) { if (!companyId) return; try { - const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url; - if (url) window.open(url, '_blank', 'noopener'); - else toast.error('El documento no tiene archivo'); + if (d.file_key) { + // Descarga por el backend (evita exponer MinIO / host interno) + const blob = await downloadBlob(d.file_key, companyId); + const url = URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } else if (d.file_url) { + window.open(d.file_url, '_blank', 'noopener'); + } else { + toast.error('El documento no tiene archivo'); + } } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo'); } diff --git a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte index 99de7a1..6c282fe 100644 --- a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte +++ b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte @@ -4,10 +4,14 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; + import { onMount } from 'svelte'; import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm'; import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; + onMount(() => void crmCatalogs.ensure('medio_contacto')); + let items = $state([]); let loading = $state(false); let search = $state(''); @@ -233,6 +237,13 @@ {#each LEAD_SOURCES as s (s.value)}{/each} +
+ diff --git a/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte new file mode 100644 index 0000000..f520370 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte @@ -0,0 +1,81 @@ + + +
+ + + {#if loading && !data} +

Cargando…

+ {:else if data} +
+

{data.reference ?? `Expediente #${data.id}`}

+

Etapa: {STAGE_LABEL[data.stage] ?? data.stage} · {data.status}{#if data.title} · {data.title}{/if}

+
+ + + Historia del trámite + Todos los documentos ligados a este expediente, en orden cronológico. + + + {#if data.timeline.length === 0} +

Sin movimientos aún.

+ {:else} +
    + {#each data.timeline as ev (ev.kind + '-' + ev.id)} + {@const K = KIND[ev.kind] ?? { label: ev.kind, icon: FileText }} +
  1. + + + +
    + {K.label} + {ev.reference ?? `#${ev.id}`} + {#if ev.status}{ev.status}{/if} + {formatDate(ev.created_at)} +
    +
  2. + {/each} +
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 07097d9..4ed04cc 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -160,6 +160,7 @@

{sr.reference ?? `Solicitud #${sr.id}`}

{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}

+ {#if sr.case_id}📁 Expediente{/if}
{#if sr.status === 'nueva' || sr.status === 'contacto'}{/if}