49 Commits

Author SHA1 Message Date
Ernesto Herrera
f1e6fba75d feat(crm): cotización aérea con peso/volumen (P/Vol) operacional
- 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 <noreply@anthropic.com>
2026-08-04 07:30:25 -06:00
Ernesto Herrera
36e98ee976 feat(crm): costo estimado por servicio adicional, folios visibles y sidebar por flujo
- 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 <noreply@anthropic.com>
2026-08-04 07:00:42 -06:00
Ernesto Herrera
915bdd19fe feat(crm): ampliar solicitud de servicio y encadenar el ciclo comercial
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 <noreply@anthropic.com>
2026-08-03 17:51:40 -06:00
Ernesto Herrera
87d23b3d23 feat(crm): rediseño profesional del PDF de cotización
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) <noreply@anthropic.com>
2026-07-30 11:06:16 -06:00
Ernesto Herrera
e269e46d88 fix(crm): envío de cotización por correo con aiosmtplib.send (sin doble STARTTLS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:23 -06:00
Ernesto Herrera
03055cd377 fix(crm): servir el PDF de cotización por el backend (no exponer MinIO)
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) <noreply@anthropic.com>
2026-07-30 10:33:14 -06:00
Ernesto Herrera
e09cb02b8b feat(crm): cotización — Ver PDF, Enviar por correo y pantalla de marca por tenant
- 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) <noreply@anthropic.com>
2026-07-29 13:22:24 -06:00
Ernesto Herrera
206ff450f8 feat(crm): PDF de cotización (formato maestro) + marca por tenant + envío por correo
- 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) <noreply@anthropic.com>
2026-07-29 13:18:07 -06:00
Ernesto Herrera
ef7e69ed57 feat(crm): tarifario — editor de cargos adicionales + alta manual de rutas
- 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) <noreply@anthropic.com>
2026-07-27 09:46:25 -06:00
Ernesto Herrera
fa542ddf18 feat(crm): frontend del módulo Tarifario — tarifarios, import Excel y Cotizador
- Cliente API rateSheetsAPI (CRUD, plantilla, import con vista previa, /rate-quote).
- Tarifarios: listado + importar por Excel (plantilla, vista previa, validación) +
  detalle con rutas y activar/vencer.
- Cotizador: calcula opciones de costo por proveedor desde los tarifarios vigentes.
- Nav CRM: Tarifarios y Cotizador.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:35:48 -06:00
Ernesto Herrera
2ae6901b6a feat(crm): módulo Tarifario — modelos, CRUD, import Excel y motor de costeo
- Tablas rate_sheets/lanes/breaks/charges (esquema crm) + migración con down().
- Catálogos nuevos: modo_tarifario, unidad_tarifa, concepto_cargo.
- CRUD de tarifarios y rutas; descarga de plantilla Excel por modo; import con
  vista previa y validación; alta directa desde Excel.
- Motor de costeo /rate-quote: aéreo (peso facturable + quiebres + optimización),
  marítimo FCL (por contenedor), LCL (W/M) y terrestre; suma cargos adicionales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:28:49 -06:00
Ernesto Herrera
8576ec7e37 feat(crm): catálogo Tipos de equipo/contenedor con medidas (Medidas de Equipos)
Agrega el catálogo global 'tipo_equipo' (27 opciones: contenedores marítimos,
ULD aéreos y remolques terrestres) con dimensiones en el campo extra (modo,
largo/ancho/alto, capacidad m³, tara, carga máx., pallets). Base para la sección
FCL del módulo de Cotización (T2026-07-183). Expone extra en el API de catálogos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:10:19 -06:00
Ernesto Herrera
de8557dec2 feat(crm): formularios Clientes/Prospectos y Proveedores por catálogo — T2026-07-081/082
Frontend de los catálogos de referencia en BD:
- Store crm-catalogs + cliente API; selects poblados desde /v1/crm/catalogs
  (fiscal: régimen, uso CFDI, forma/método de pago SAT, moneda ISO; comercial;
  país/estado/tipo de domicilio/área). Muestran la descripción, no la clave.
- "Otro → especificar" (medio de contacto y clasificación), observaciones
  generales, y datos de auditoría (ID, fechas, usuarios) en la vista de edición.
- Botón "Editar" explícito en los listados.
- Pantalla de administración de catálogos (insertar/editar/borrar): globales
  solo activar/desactivar; los de la empresa con alta/edición/borrado.
- addresses.country ampliado a 3 (país ISO alfa-3) + migración con down().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
Ernesto Herrera
a2a474f8c8 chore(deploy): heap de Node en build + runtime sin red + nginx buffers/http2
Cambios de infra ya aplicados en testing: NODE_OPTIONS max-old-space-size para
evitar OOM en vite build; runtime del frontend sin apk/npm (busybox/node directo);
nginx con proxy_buffers grandes (evita 502 por header del JWT) y http2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
Ernesto Herrera
8aef99df9c feat(crm): catálogos de referencia en BD (SAT/ISO + cliente) — T2026-07-081/082
- Modelo crm.catalog_items (global tenant_id NULL / por tenant) + migración con
  índices únicos parciales y down().
- Seed de 18 catálogos globales (485 opciones): tipo registro/persona, estatus,
  giro, clasificaciones, medio contacto, idioma, régimen, uso CFDI, forma/método
  de pago SAT, moneda/país ISO, estados MX, tipo domicilio, área, cobertura.
- Servicio + endpoints CRUD /v1/crm/catalogs (listar/insertar/editar/borrar),
  con global solo para hub_admin y catálogos del cliente por tenant.
- Columnas nuevas: accounts.commercial_observations, accounts.preferred_contact_other,
  suppliers.classification_other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:15:52 -06:00
Ernesto Herrera
5f9c7cf000 docs(deploy): runbook de despliegue a producción (auth SIWEB + RBAC)
Pasos para que un operador con acceso a prod despliegue con seguridad: env,
build del frontend en CI, up del stack, seed + sync de permisos, smoke test y
rollback. Incluye feature-flag SESSION_STORE_ENABLED como mitigación rápida.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:34:30 -06:00
Ernesto Herrera
71be225b33 fix(crm): guardar permisos de rol y recargar roles/usuarios tras refresh
- 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) <noreply@anthropic.com>
2026-07-20 06:43:35 -06:00
Ernesto Herrera
0cffd351df fix(crm): alta de usuarios carga compañías del tenant en vez de slug a mano
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) <noreply@anthropic.com>
2026-07-17 17:03:36 -06:00
Ernesto Herrera
a14acf59e5 chore(crm): ocultar pestaña Workspace del sidebar
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) <noreply@anthropic.com>
2026-07-17 16:56:59 -06:00
Ernesto Herrera
0d8c4ddf62 fix(crm): catálogo de permisos no cargaba por redirect 307 (slash final)
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) <noreply@anthropic.com>
2026-07-17 16:51:18 -06:00
Ernesto Herrera
de9e35c501 feat(core): botón "dar de alta usuario" (invitación) en Usuarios + fix hub_admin
- 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) <noreply@anthropic.com>
2026-07-17 16:12:11 -06:00
Ernesto Herrera
868724d1f2 fix(core): usar token KC (valkey) para listar usuarios del tenant, no la sesión local
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) <noreply@anthropic.com>
2026-07-17 16:03:37 -06:00
Ernesto Herrera
cd3f8c62a4 feat(core): gestión de Usuarios y Roles/permisos (pantallas)
- 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) <noreply@anthropic.com>
2026-07-17 13:31:11 -06:00
Ernesto Herrera
387b3c0e78 feat(core,crm): auto-sync de tenants Workspace→CRM + mostrar tenant de la compañía activa
- 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) <noreply@anthropic.com>
2026-07-17 13:20:50 -06:00
Ernesto Herrera
706da34f4a feat(core): auto-ligar usuario a todas las compañías de su tenant (sin rol)
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) <noreply@anthropic.com>
2026-07-17 12:44:51 -06:00
Ernesto Herrera
ce8b042e84 fix(auth): hornear is_hub_admin (autoritativo del Hub) en la sesión local
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) <noreply@anthropic.com>
2026-07-17 12:27:27 -06:00
Ernesto Herrera
63ad2e2ecd feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant
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) <noreply@anthropic.com>
2026-07-17 11:44:36 -06:00
Ernesto Herrera
0b6848bbdd fix(core): crear compañía real en a76.company por tenant (arregla FK y desbloqueo)
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) <noreply@anthropic.com>
2026-07-17 10:31:59 -06:00
Ernesto Herrera
29170f7c8c fix(auth): validar licencia sin reenviar la sesión local al Hub (rompe el bucle 401)
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) <noreply@anthropic.com>
2026-07-17 09:43:32 -06:00
Ernesto Herrera
b76d42be83 refactor(auth): login 100% vía Workspace/Hub — sin comunicación directa a Keycloak
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) <noreply@anthropic.com>
2026-07-17 09:01:10 -06:00
Ernesto Herrera
223395b430 feat(core): compañía por tenant (1:1) en my-companies para desbloquear el dashboard
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) <noreply@anthropic.com>
2026-07-16 18:05:44 -06:00
Ernesto Herrera
579c6e1f19 chore(deploy): activar sesión local (patrón SIWEB) en testing
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) <noreply@anthropic.com>
2026-07-16 17:03:01 -06:00
Ernesto Herrera
5d1c65f236 feat(auth): sesión local del CRM (patrón SIWEB) para eliminar el bucle de login
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) <noreply@anthropic.com>
2026-07-16 16:50:35 -06:00
Ernesto Herrera
a613a7a6aa feat(crm,workspace): alta de organizaciones y usuarios vía Hub
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) <noreply@anthropic.com>
2026-07-16 13:19:21 -06:00
Aduanasoft
45f128a551 fix(deploy): apk add wget best-effort en Dockerfile.prod (redes sin CDN de Alpine)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:54:57 -06:00
Aduanasoft
c6f18013b3 chore(deploy): override de testing (build de producción del frontend + loopback)
Frontend construido con Dockerfile.prod (adapter-node, VITE_* horneadas), backend
uvicorn sin reload, puertos solo en 127.0.0.1 (nginx del host hace TLS/proxy),
postgres/minio sin publicar. Para testing.crm.aduanasoft.com con auth real vía Hub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:50:21 -06:00
Aduanasoft
f03ac38c4f docs(deploy): env de testing con auth real vía Hub (SSO relay, realm master, client aduanasoft)
Corrige la receta de integración según el código del Hub: single-realm (master) +
single-client (aduanasoft), HUB_URL para el sso-exchange del relay, y URLs del
dominio del CRM. Secretos en blanco (los pone el operador en el server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 08:55:28 -06:00
Aduanasoft
5a250204b5 chore(deploy): override de preview privado (puertos solo en loopback)
Para levantar el stack en el servidor de pruebas con acceso por túnel SSH sin
exponer nada a internet: backend/frontend publicados solo en 127.0.0.1 y
postgres/minio sin publicar al host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:37:17 -06:00
Aduanasoft
c983c744ac chore(deploy): nginx + entorno + runbook para testing.crm.aduanasoft.com
- deploy/nginx/testing.crm.aduanasoft.com.conf: reverse proxy TLS, app (:5173) y
  API (:8000) en el mismo origen, headers de seguridad, límite 30 MB de subida.
- deploy/env.testing.example: plantilla de entorno SIN secretos, con banderas de
  seguridad (ENVIRONMENT=production, DEV_LOCAL_AUTH=false) y URLs del dominio.
- deploy/README.md: runbook (acceso por llave, build de producción, migraciones,
  nginx+certbot, smoke test). Los secretos y las migraciones los ejecuta el operador.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:19:21 -06:00
Aduanasoft
e806512a89 feat(ops,fin,crm): frontend de las reglas del PDF (decisiones, cierre, PDF factura, continuidad)
- Embarque: "Cerrar operación" (costos finales, habilita facturar), "Reprogramar
  salida" (Cut Off), y bitácora con puntos de decisión (autorizar/rechazar → hito
  de corrección) + transporte terrestre/recolección en Datos.
- Factura: "Enviar (PDF)" que genera y guarda el PDF, "Ver PDF", y revisión del
  cliente (en revisión / aprueba / con observaciones); estado en_revision_cliente.
- Solicitud: "Registrar contacto" y "Re-cotizar"; Cotización: "Re-cotizar (clonar)";
  Oportunidad: "Convertir a solicitud" (enlaza embudo→RFQ).
- Clientes API ampliados (ops/fin/crm) + catálogos (incoterms/participantes) +
  etiquetas de estado. svelte-check: 0 errores de tipo en archivos nuevos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:08:07 -06:00
Aduanasoft
3ea8d5f3ef fix(core,crm): habilitar RBAC por carril sin romper el entorno dev
- Crea el módulo faltante permissions/seed_v2.py (register_core_permissions), que
  sync_permissions y bootstrap_super_admin importan; sin él ambos fallaban con
  ImportError y el auto-bootstrap dev quedaba inoperante.
- seed: ensure_company (a76.company id=1, requerida por la FK de company_roles) y
  seed_carril_roles (Ventas, Operaciones, Facturación, Consulta) con sus permisos;
  pobla el PermissionRegistry importando los permisos de cada dominio.
- Verificado end-to-end: con enforcement activo el usuario dev auto-bootstrapea a
  super_admin y crm/ops/fin responden 200 (no 403).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:07:53 -06:00
Aduanasoft
e79705e6e3 feat(ops,fin,crm): reglas de negocio del PDF (decisiones, cierre, facturación, continuidad, RBAC)
Cierra los huecos de la auditoría contra "SOFTWARE PARA AGENTES DE CARGA":

- ops (Diag. 2/3): bitácora con puntos de decisión (kind=decision) y ciclo de
  corrección (parent_event_id/attempt) para ¿Cut Off? y ¿despacho autorizado?
  (R-E-05/13, R-I-06). Reprogramación de salida (previous_etd, R-E-06). Hitos
  operativos completos export/import. Cierre operativo con costos finales
  (close_shipment, R-E-22).
- fin (Diag. 4): facturación con gate por cierre operativo y sin duplicar
  (R-F-01), costos de operación arrastrados (ops_cost_total, R-F-02), envío con
  PDF generado y guardado en MinIO (send_invoice + pdf.py sin dependencias,
  R-F-05) y revisión del cliente (en_revision_cliente + aprobación, R-F-06).
- crm (Diag. 1): opportunity_id enlaza embudo→RFQ (R-C-02), contacto como etapa
  (first_contact_at, R-C-04), re-cotización (clone_quote + reopen, R-C-12).
- transversal: catálogo de Incoterms y participantes/actores incl. autoridad
  aduanera (R-T-01/10), enforcement de permisos por carril (RBAC) con roles
  sembrados y dependencias dev-safe (R-T-07).
- Migración d5e6f7a8b9c0 con downgrade. Seed extendido. 70 tests (12 nuevos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:46:53 -06:00
Aduanasoft
116d2e7f5a chore(crm): registrar router de uploads en el agregador del CRM
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:24:28 -06:00
Aduanasoft
e724aeae50 feat(fin,ops): frontend Facturas/Cobranza, bitácora de embarque y uploader de documentos
- Facturas: lista, alta y detalle (Conceptos / Pagos-cobranza / Datos) con
  totales+IVA+saldo y acciones Emitir/Enviar/Cancelar
- Embarque: botón "Generar factura", pestaña "Bitácora" (hitos por defecto
  import/export, completar/agregar) y subida real de archivos a MinIO en documentos
- Clientes/Proveedores: subida de archivos en documentos (RelatedManager)
- clientes API fin + ops(eventos) + uploads; sidebar grupo Facturación
- svelte-check: 0 errores de tipo en archivos nuevos

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:24:15 -06:00
Aduanasoft
0b12ad5354 feat(fin,ops): Facturación y Cobranza (Diag. 4), bitácora de embarque (Diag. 3) y subida a MinIO
- schema fin: fin.invoices + fin.invoice_items + fin.payments; totales con IVA,
  estados borrador→emitida→enviada→pagada, cobranza (pagos) y saldo automático
- generar-factura-desde-embarque (toma conceptos de venta de la cotización)
- ops.shipment_events: bitácora/hitos del embarque con secuencia por defecto
  según operación (importación/exportación) — cubre Diagrama 3
- subida de documentos a MinIO: POST /crm/uploads (multipart) + URL firmada
- migración c4d5e6f7a8b9, routers/permisos (fin), seed del flujo hasta factura
- 58 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:18:11 -06:00
Aduanasoft
6f200b4505 feat(crm,ops): frontend de Solicitudes, Cotizaciones y Embarques (páginas con pestañas)
- Solicitudes (RFQ): alta + detalle (Requerimientos / Tarifas)
- Cotizaciones: detalle con conceptos (costo/venta/margen), totales, acciones
  Enviar/Aceptar/Rechazar y "Liberar a Operaciones"
- Embarques: alta + detalle (Datos / Documentos Master-House)
- clientes API comercial + ops; sidebar CRM + grupo Operaciones
- svelte-check: 0 errores de tipo en archivos del CRM/OPS

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:21:25 -06:00
Aduanasoft
a196c44fae feat(crm,ops): proceso comercial (solicitudes/RFQ, tarifas, cotizaciones) + Operaciones (embarques)
Diagrama 1 (CRM comercial) y Diagrama 2 (Operaciones) del spec de agente de carga:
- crm.service_requests (RFQ) + crm.rate_requests (solicitud de tarifas a proveedores)
- crm.quotes + crm.quote_items: conceptos costo/venta/margen, totales automáticos,
  estados borrador→enviada→aceptada/rechazada
- schema ops: ops.shipments (booking, Cut Off, ETD/ETA, naviera/agente aduanal/destino)
  y ops.shipment_documents (MBL/HBL, MAWB/HAWB, CMR…)
- liberar-a-operaciones: crea el embarque desde la cotización aceptada
- migración b3c4d5e6f7a8, routers/permisos, seed del flujo completo
- 52 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:21:25 -06:00
Aduanasoft
c26e04bcff refactor(crm): catálogos como páginas completas con pestañas (sin modales)
- Clientes/Prospectos y Proveedores: alta y edición en páginas dedicadas
  (/nuevo y /[id]) respetando el shell del dashboard, en vez de modales
- Info segmentada en pestañas: Generales · Comercial · Fiscal · Observaciones
  · Direcciones · Contactos · Documentos
- Componentes reutilizables AccountFields/SupplierFields; RelatedManager
  ahora renderiza por sección
- svelte-check: 0 errores de tipo en archivos del CRM

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:44:21 -06:00
Aduanasoft
79135d9b5a feat(crm): frontend de catálogos (Proveedores, Clientes enriquecido, detalle)
- Clientes/Prospectos: formulario por secciones (generales, comercial, fiscal)
- Proveedores: nueva página con clasificación múltiple y coberturas
- Páginas de detalle cliente/proveedor con gestión de múltiples direcciones,
  contactos y documentos (componente RelatedManager reutilizable)
- Clientes API tipados (suppliers, addresses, documents) + sidebar actualizado
- svelte-check sin errores de tipo en los archivos del CRM

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:19:14 -06:00
169 changed files with 17731 additions and 657 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,232 @@
"""crm commercial (service_requests, rate_requests, quotes, quote_items) + ops (shipments, documents)
Revision ID: b3c4d5e6f7a8
Revises: a7b8c9d0e1f2
Create Date: 2026-07-14 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b3c4d5e6f7a8"
down_revision: Union[str, None] = "a7b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _scoped_columns() -> list[sa.Column]:
return [
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),
]
def _scoped_indexes(table: str, schema: str) -> None:
op.create_index(f"ix_{schema}_{table}_id", table, ["id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_tenant_id", table, ["tenant_id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_company_id", table, ["company_id"], schema=schema)
def upgrade() -> None:
# ---------- crm.service_requests ----------
op.create_table(
"service_requests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("operation_type", sa.String(length=20), nullable=False),
sa.Column("transport_mode", sa.String(length=20), nullable=True),
sa.Column("service_type", sa.String(length=20), nullable=True),
sa.Column("incoterm", sa.String(length=10), nullable=True),
sa.Column("origin", sa.String(length=160), nullable=True),
sa.Column("destination", sa.String(length=160), nullable=True),
sa.Column("cargo_type", sa.String(length=120), nullable=True),
sa.Column("weight", sa.Numeric(precision=14, scale=3), nullable=True),
sa.Column("volume", sa.Numeric(precision=14, scale=3), nullable=True),
sa.Column("load_type", sa.String(length=10), nullable=True),
sa.Column("container_equipment", sa.String(length=120), nullable=True),
sa.Column("commodity", sa.Text(), nullable=True),
sa.Column("required_date", sa.Date(), nullable=True),
sa.Column("destination_agent_id", sa.Integer(), nullable=True),
sa.Column("requirements", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'nueva'")),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]),
schema="crm",
)
_scoped_indexes("service_requests", "crm")
op.create_index("ix_crm_service_requests_reference", "service_requests", ["reference"], schema="crm")
op.create_index("ix_crm_service_requests_account_id", "service_requests", ["account_id"], schema="crm")
op.create_index("ix_crm_service_requests_status", "service_requests", ["status"], schema="crm")
op.create_index("ix_crm_service_requests_owner_user_id", "service_requests", ["owner_user_id"], schema="crm")
# ---------- crm.rate_requests ----------
op.create_table(
"rate_requests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_request_id", sa.Integer(), nullable=False),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'solicitada'")),
sa.Column("rate_amount", sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=True),
sa.Column("valid_until", sa.Date(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]),
schema="crm",
)
_scoped_indexes("rate_requests", "crm")
op.create_index("ix_crm_rate_requests_service_request_id", "rate_requests", ["service_request_id"], schema="crm")
op.create_index("ix_crm_rate_requests_supplier_id", "rate_requests", ["supplier_id"], schema="crm")
# ---------- crm.quotes ----------
op.create_table(
"quotes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("service_request_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'USD'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
sa.Column("issue_date", sa.Date(), nullable=True),
sa.Column("valid_until", sa.Date(), nullable=True),
sa.Column("total_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("total_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.Column("rejected_at", sa.DateTime(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("terms", sa.Text(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="crm",
)
_scoped_indexes("quotes", "crm")
op.create_index("ix_crm_quotes_reference", "quotes", ["reference"], schema="crm")
op.create_index("ix_crm_quotes_service_request_id", "quotes", ["service_request_id"], schema="crm")
op.create_index("ix_crm_quotes_account_id", "quotes", ["account_id"], schema="crm")
op.create_index("ix_crm_quotes_status", "quotes", ["status"], schema="crm")
op.create_index("ix_crm_quotes_owner_user_id", "quotes", ["owner_user_id"], schema="crm")
# ---------- crm.quote_items ----------
op.create_table(
"quote_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("quote_id", sa.Integer(), nullable=False),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("quantity", sa.Numeric(precision=12, scale=2), nullable=False, server_default=sa.text("1")),
sa.Column("unit_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("unit_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("currency", sa.String(length=3), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]),
schema="crm",
)
_scoped_indexes("quote_items", "crm")
op.create_index("ix_crm_quote_items_quote_id", "quote_items", ["quote_id"], schema="crm")
# ---------- schema ops ----------
op.execute("CREATE SCHEMA IF NOT EXISTS ops")
op.create_table(
"shipments",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("quote_id", sa.Integer(), nullable=True),
sa.Column("service_request_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("operation_type", sa.String(length=20), nullable=True),
sa.Column("transport_mode", sa.String(length=20), nullable=True),
sa.Column("service_type", sa.String(length=20), nullable=True),
sa.Column("incoterm", sa.String(length=10), nullable=True),
sa.Column("origin", sa.String(length=160), nullable=True),
sa.Column("destination", sa.String(length=160), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierta'")),
sa.Column("booking_number", sa.String(length=60), nullable=True),
sa.Column("carrier_supplier_id", sa.Integer(), nullable=True),
sa.Column("customs_agent_id", sa.Integer(), nullable=True),
sa.Column("destination_agent_id", sa.Integer(), nullable=True),
sa.Column("cutoff_date", sa.DateTime(), nullable=True),
sa.Column("etd", sa.Date(), nullable=True),
sa.Column("eta", sa.Date(), nullable=True),
sa.Column("vessel_flight", sa.String(length=120), nullable=True),
sa.Column("container_number", sa.String(length=60), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
sa.ForeignKeyConstraint(["carrier_supplier_id"], ["crm.suppliers.id"]),
sa.ForeignKeyConstraint(["customs_agent_id"], ["crm.suppliers.id"]),
sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]),
schema="ops",
)
_scoped_indexes("shipments", "ops")
op.create_index("ix_ops_shipments_reference", "shipments", ["reference"], schema="ops")
op.create_index("ix_ops_shipments_quote_id", "shipments", ["quote_id"], schema="ops")
op.create_index("ix_ops_shipments_account_id", "shipments", ["account_id"], schema="ops")
op.create_index("ix_ops_shipments_status", "shipments", ["status"], schema="ops")
op.create_index("ix_ops_shipments_owner_user_id", "shipments", ["owner_user_id"], schema="ops")
op.create_table(
"shipment_documents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("shipment_id", sa.Integer(), nullable=False),
sa.Column("doc_kind", sa.String(length=10), nullable=False, server_default=sa.text("'otro'")),
sa.Column("doc_type", sa.String(length=30), nullable=False),
sa.Column("number", sa.String(length=80), nullable=True),
sa.Column("issue_date", sa.Date(), nullable=True),
sa.Column("file_url", sa.String(length=1024), nullable=True),
sa.Column("file_key", sa.String(length=512), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
schema="ops",
)
_scoped_indexes("shipment_documents", "ops")
op.create_index("ix_ops_shipment_documents_shipment_id", "shipment_documents", ["shipment_id"], schema="ops")
def downgrade() -> None:
op.drop_table("shipment_documents", schema="ops")
op.drop_table("shipments", schema="ops")
op.execute("DROP SCHEMA IF EXISTS ops")
op.drop_table("quote_items", schema="crm")
op.drop_table("quotes", schema="crm")
op.drop_table("rate_requests", schema="crm")
op.drop_table("service_requests", schema="crm")

View File

@@ -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)

View File

@@ -0,0 +1,140 @@
"""fin (invoices, invoice_items, payments) + ops.shipment_events
Revision ID: c4d5e6f7a8b9
Revises: b3c4d5e6f7a8
Create Date: 2026-07-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c4d5e6f7a8b9"
down_revision: Union[str, None] = "b3c4d5e6f7a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _scoped_columns() -> list[sa.Column]:
return [
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),
]
def _scoped_indexes(table: str, schema: str) -> None:
op.create_index(f"ix_{schema}_{table}_id", table, ["id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_tenant_id", table, ["tenant_id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_company_id", table, ["company_id"], schema=schema)
def upgrade() -> None:
# ---------- schema fin ----------
op.execute("CREATE SCHEMA IF NOT EXISTS fin")
op.create_table(
"invoices",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("shipment_id", sa.Integer(), nullable=True),
sa.Column("quote_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
sa.Column("issue_date", sa.Date(), nullable=True),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column("subtotal", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("tax_rate", sa.Numeric(precision=5, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("tax_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("total", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("paid_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("balance", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("bank_info", sa.Text(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("paid_at", sa.DateTime(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="fin",
)
_scoped_indexes("invoices", "fin")
op.create_index("ix_fin_invoices_reference", "invoices", ["reference"], schema="fin")
op.create_index("ix_fin_invoices_shipment_id", "invoices", ["shipment_id"], schema="fin")
op.create_index("ix_fin_invoices_account_id", "invoices", ["account_id"], schema="fin")
op.create_index("ix_fin_invoices_status", "invoices", ["status"], schema="fin")
op.create_index("ix_fin_invoices_owner_user_id", "invoices", ["owner_user_id"], schema="fin")
op.create_table(
"invoice_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("quantity", sa.Numeric(precision=12, scale=2), nullable=False, server_default=sa.text("1")),
sa.Column("unit_amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
schema="fin",
)
_scoped_indexes("invoice_items", "fin")
op.create_index("ix_fin_invoice_items_invoice_id", "invoice_items", ["invoice_id"], schema="fin")
op.create_table(
"payments",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("invoice_id", sa.Integer(), nullable=False),
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False),
sa.Column("payment_date", sa.Date(), nullable=True),
sa.Column("method", sa.String(length=40), nullable=True),
sa.Column("reference", sa.String(length=120), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
schema="fin",
)
_scoped_indexes("payments", "fin")
op.create_index("ix_fin_payments_invoice_id", "payments", ["invoice_id"], schema="fin")
# ---------- ops.shipment_events ----------
op.create_table(
"shipment_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("shipment_id", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=60), nullable=True),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pendiente'")),
sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("planned_date", sa.DateTime(), nullable=True),
sa.Column("actual_date", sa.DateTime(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
schema="ops",
)
_scoped_indexes("shipment_events", "ops")
op.create_index("ix_ops_shipment_events_shipment_id", "shipment_events", ["shipment_id"], schema="ops")
def downgrade() -> None:
op.drop_table("shipment_events", schema="ops")
op.drop_table("payments", schema="fin")
op.drop_table("invoice_items", schema="fin")
op.drop_table("invoices", schema="fin")
op.execute("DROP SCHEMA IF EXISTS fin")

View File

@@ -0,0 +1,92 @@
"""Cumplimiento PDF agente de carga: decisiones/costos en ops, envío/revisión en fin,
continuidad comercial en crm (opportunity_id, contacto).
Revision ID: d5e6f7a8b9c0
Revises: c4d5e6f7a8b9
Create Date: 2026-07-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d5e6f7a8b9c0"
down_revision: Union[str, None] = "c4d5e6f7a8b9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ---------- ops.shipments: transporte terrestre, reprogramación y cierre operativo ----------
op.add_column("shipments", sa.Column("ground_carrier_supplier_id", sa.Integer(), nullable=True), schema="ops")
op.add_column("shipments", sa.Column("pickup_at", sa.DateTime(), nullable=True), schema="ops")
op.add_column("shipments", sa.Column("previous_etd", sa.Date(), nullable=True), schema="ops")
op.add_column("shipments", sa.Column("actual_cost_total", sa.Numeric(precision=14, scale=2), nullable=True), schema="ops")
op.add_column("shipments", sa.Column("cost_currency", sa.String(length=3), nullable=True), schema="ops")
op.add_column("shipments", sa.Column("closed_at", sa.DateTime(), nullable=True), schema="ops")
op.add_column("shipments", sa.Column("closed_by", sa.String(length=64), nullable=True), schema="ops")
op.create_foreign_key(
"fk_ops_shipments_ground_carrier_supplier_id", "shipments", "suppliers",
["ground_carrier_supplier_id"], ["id"], source_schema="ops", referent_schema="crm",
)
# ---------- ops.shipment_events: puntos de decisión y ciclo de corrección ----------
op.add_column("shipment_events", sa.Column("kind", sa.String(length=20), nullable=False, server_default=sa.text("'hito'")), schema="ops")
op.add_column("shipment_events", sa.Column("outcome", sa.String(length=20), nullable=True), schema="ops")
op.add_column("shipment_events", sa.Column("parent_event_id", sa.Integer(), nullable=True), schema="ops")
op.add_column("shipment_events", sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("1")), schema="ops")
op.create_foreign_key(
"fk_ops_shipment_events_parent_event_id", "shipment_events", "shipment_events",
["parent_event_id"], ["id"], source_schema="ops", referent_schema="ops",
)
# ---------- fin.invoices: costos de operación, PDF y revisión del cliente ----------
op.add_column("invoices", sa.Column("ops_cost_total", sa.Numeric(precision=14, scale=2), nullable=True), schema="fin")
op.add_column("invoices", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema="fin")
op.add_column("invoices", sa.Column("client_reviewed_at", sa.DateTime(), nullable=True), schema="fin")
op.add_column("invoices", sa.Column("client_approved", sa.Boolean(), nullable=True), schema="fin")
op.add_column("invoices", sa.Column("review_notes", sa.Text(), nullable=True), schema="fin")
# ---------- crm.service_requests: continuidad comercial y contacto ----------
op.add_column("service_requests", sa.Column("opportunity_id", sa.Integer(), nullable=True), schema="crm")
op.add_column("service_requests", sa.Column("first_contact_at", sa.DateTime(), nullable=True), schema="crm")
op.add_column("service_requests", sa.Column("first_contact_notes", sa.Text(), nullable=True), schema="crm")
op.create_index("ix_crm_service_requests_opportunity_id", "service_requests", ["opportunity_id"], schema="crm")
op.create_foreign_key(
"fk_crm_service_requests_opportunity_id", "service_requests", "opportunities",
["opportunity_id"], ["id"], source_schema="crm", referent_schema="crm",
)
def downgrade() -> None:
# crm.service_requests
op.drop_constraint("fk_crm_service_requests_opportunity_id", "service_requests", schema="crm", type_="foreignkey")
op.drop_index("ix_crm_service_requests_opportunity_id", table_name="service_requests", schema="crm")
op.drop_column("service_requests", "first_contact_notes", schema="crm")
op.drop_column("service_requests", "first_contact_at", schema="crm")
op.drop_column("service_requests", "opportunity_id", schema="crm")
# fin.invoices
op.drop_column("invoices", "review_notes", schema="fin")
op.drop_column("invoices", "client_approved", schema="fin")
op.drop_column("invoices", "client_reviewed_at", schema="fin")
op.drop_column("invoices", "pdf_file_key", schema="fin")
op.drop_column("invoices", "ops_cost_total", schema="fin")
# ops.shipment_events
op.drop_constraint("fk_ops_shipment_events_parent_event_id", "shipment_events", schema="ops", type_="foreignkey")
op.drop_column("shipment_events", "attempt", schema="ops")
op.drop_column("shipment_events", "parent_event_id", schema="ops")
op.drop_column("shipment_events", "outcome", schema="ops")
op.drop_column("shipment_events", "kind", schema="ops")
# ops.shipments
op.drop_constraint("fk_ops_shipments_ground_carrier_supplier_id", "shipments", schema="ops", type_="foreignkey")
op.drop_column("shipments", "closed_by", schema="ops")
op.drop_column("shipments", "closed_at", schema="ops")
op.drop_column("shipments", "cost_currency", schema="ops")
op.drop_column("shipments", "actual_cost_total", schema="ops")
op.drop_column("shipments", "previous_etd", schema="ops")
op.drop_column("shipments", "pickup_at", schema="ops")
op.drop_column("shipments", "ground_carrier_supplier_id", schema="ops")

View File

@@ -0,0 +1,90 @@
"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers
Revision ID: e6f7a8b9c0d1
Revises: d5e6f7a8b9c0
Create Date: 2026-07-22 00:00:00.000000
Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores):
catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes
(observaciones comerciales, "otro" de medio de contacto y de clasificación).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e6f7a8b9c0d1"
down_revision: Union[str, None] = "d5e6f7a8b9c0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
# ----- crm.catalog_items -----
op.create_table(
"catalog_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("catalog", sa.String(length=60), nullable=False),
sa.Column("code", sa.String(length=64), nullable=False),
sa.Column("label", sa.String(length=255), nullable=False),
sa.Column("parent_catalog", sa.String(length=60), nullable=True),
sa.Column("parent_code", sa.String(length=64), nullable=True),
# NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant.
sa.Column("tenant_id", sa.Integer(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("extra", sa.JSON(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
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"),
schema=SCHEMA,
)
op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA)
op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA)
op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA)
op.create_index(
"ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA
)
# Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas.
op.create_index(
"uq_crm_catalog_items_global",
"catalog_items",
["catalog", "code"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("tenant_id IS NULL"),
)
op.create_index(
"uq_crm_catalog_items_tenant",
"catalog_items",
["catalog", "code", "tenant_id"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("tenant_id IS NOT NULL"),
)
# ----- columnas nuevas -----
# Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto.
op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA)
op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA)
# Proveedores: "otro" de la clasificación múltiple.
op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA)
def downgrade() -> None:
op.drop_column("suppliers", "classification_other", schema=SCHEMA)
op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA)
op.drop_column("accounts", "commercial_observations", schema=SCHEMA)
op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA)
op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA)
op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA)
op.drop_table("catalog_items", schema=SCHEMA)

View File

@@ -0,0 +1,44 @@
"""ampliar crm.addresses.country a 3 (país ISO alfa-3 del catálogo)
Revision ID: e7f8a9b0c1d2
Revises: e6f7a8b9c0d1
Create Date: 2026-07-22 00:30:00.000000
El catálogo de País usa códigos ISO 3166 alfa-3 (MEX, USA, …). La columna
addresses.country era String(2); se amplía a String(3) para almacenarlos.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e7f8a9b0c1d2"
down_revision: Union[str, None] = "e6f7a8b9c0d1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.alter_column(
"addresses", "country",
type_=sa.String(length=3),
existing_type=sa.String(length=2),
existing_nullable=True,
server_default=sa.text("'MEX'"),
schema=SCHEMA,
)
def downgrade() -> None:
# Trunca a 2 chars por si hay códigos alfa-3 guardados (rollback de dev).
op.execute("UPDATE crm.addresses SET country = left(country, 2) WHERE length(country) > 2")
op.alter_column(
"addresses", "country",
type_=sa.String(length=2),
existing_type=sa.String(length=3),
existing_nullable=True,
server_default=sa.text("'MX'"),
schema=SCHEMA,
)

View File

@@ -0,0 +1,131 @@
"""crm rates: tarifarios (rate_sheets/lanes/breaks/charges)
Revision ID: f8a9b0c1d2e3
Revises: e7f8a9b0c1d2
Create Date: 2026-07-27 00:00:00.000000
Módulo Tarifario: base de costos para Cotizaciones (import por Excel + motor de costeo).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f8a9b0c1d2e3"
down_revision: Union[str, None] = "e7f8a9b0c1d2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def _scoped() -> list[sa.Column]:
return [
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),
]
def _idx(table: str) -> None:
op.create_index(f"ix_{SCHEMA}_{table}_id", table, ["id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_tenant_id", table, ["tenant_id"], schema=SCHEMA)
op.create_index(f"ix_{SCHEMA}_{table}_company_id", table, ["company_id"], schema=SCHEMA)
def upgrade() -> None:
# ----- rate_sheets -----
op.create_table(
"rate_sheets",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("mode", sa.String(length=20), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("currency", sa.String(length=3), nullable=True, server_default=sa.text("'USD'")),
sa.Column("valid_from", sa.Date(), nullable=True),
sa.Column("valid_to", sa.Date(), nullable=True),
sa.Column("default_origin", sa.String(length=20), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
sa.Column("source_file", sa.String(length=512), nullable=True),
sa.Column("source_url", sa.String(length=1024), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["supplier_id"], [f"{SCHEMA}.suppliers.id"]),
schema=SCHEMA,
)
_idx("rate_sheets")
op.create_index("ix_crm_rate_sheets_mode", "rate_sheets", ["mode"], schema=SCHEMA)
op.create_index("ix_crm_rate_sheets_supplier_id", "rate_sheets", ["supplier_id"], schema=SCHEMA)
# ----- rate_lanes -----
op.create_table(
"rate_lanes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("rate_sheet_id", sa.Integer(), nullable=False),
sa.Column("origin", sa.String(length=20), nullable=True),
sa.Column("destination", sa.String(length=20), nullable=True),
sa.Column("region", sa.String(length=60), nullable=True),
sa.Column("equipment_type", sa.String(length=20), nullable=True),
sa.Column("rate_unit", sa.String(length=20), nullable=True),
sa.Column("min_charge", sa.Numeric(precision=14, scale=4), nullable=True),
sa.Column("flat_rate", sa.Numeric(precision=14, scale=4), nullable=True),
sa.Column("transit_days", sa.Integer(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
schema=SCHEMA,
)
_idx("rate_lanes")
op.create_index("ix_crm_rate_lanes_rate_sheet_id", "rate_lanes", ["rate_sheet_id"], schema=SCHEMA)
op.create_index("ix_crm_rate_lanes_origin", "rate_lanes", ["origin"], schema=SCHEMA)
op.create_index("ix_crm_rate_lanes_destination", "rate_lanes", ["destination"], schema=SCHEMA)
# ----- rate_breaks -----
op.create_table(
"rate_breaks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("rate_lane_id", sa.Integer(), nullable=False),
sa.Column("from_qty", sa.Numeric(precision=12, scale=3), nullable=False, server_default=sa.text("0")),
sa.Column("rate", sa.Numeric(precision=14, scale=4), nullable=False, server_default=sa.text("0")),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
schema=SCHEMA,
)
_idx("rate_breaks")
op.create_index("ix_crm_rate_breaks_rate_lane_id", "rate_breaks", ["rate_lane_id"], schema=SCHEMA)
# ----- rate_charges -----
op.create_table(
"rate_charges",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("rate_sheet_id", sa.Integer(), nullable=True),
sa.Column("rate_lane_id", sa.Integer(), nullable=True),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("charge_type", sa.String(length=20), nullable=False, server_default=sa.text("'fijo'")),
sa.Column("value", sa.Numeric(precision=14, scale=4), nullable=True),
sa.Column("condition", sa.Text(), nullable=True),
*_scoped(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
schema=SCHEMA,
)
_idx("rate_charges")
def downgrade() -> None:
op.drop_table("rate_charges", schema=SCHEMA)
op.drop_table("rate_breaks", schema=SCHEMA)
op.drop_table("rate_lanes", schema=SCHEMA)
op.drop_table("rate_sheets", schema=SCHEMA)

View File

@@ -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):

View File

@@ -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()
@@ -402,10 +408,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 +427,239 @@ 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.
from core.security import (
resolve_effective_tenant_id_from_user,
_ensure_user_tenant_for_company,
)
from api.v1.modules.core.tenants.models import Tenant
from sqlalchemy import text
user_id = current_user.get("sub") or current_user.get("id")
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 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)
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)
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()
company_ids = [int(created[0])]
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
# 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, cid)
except Exception as 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ó
# 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 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
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()
return [
{
"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,
}
for r in rows
]
class _CreateCompanyDTO(BaseModel):
name: str
tenant_id: int
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. 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
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:
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)
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.")
# 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:
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}

View File

@@ -213,55 +213,160 @@ 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 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
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 _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 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 = 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.
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:
"""

View File

@@ -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,
)

View File

@@ -3,14 +3,19 @@ Dependencias de FastAPI para verificación de permisos multi-tenant.
Proporciona decoradores y funciones para proteger rutas con permisos específicos.
"""
import logging
from typing import List, Optional, Callable
from fastapi import Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from functools import wraps
from core.config import settings
from core.database import get_core_db
from core.security import get_current_user # Asumiendo que existe esta función
from .service import PermissionService
logger = logging.getLogger(__name__)
# Dependencia para obtener el servicio de permisos
def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionService:
"""
@@ -19,6 +24,34 @@ def get_permission_service(db: Session = Depends(get_core_db)) -> PermissionServ
return PermissionService(db)
def _authorize(
permission_service: PermissionService,
user_id: str,
company_id: int,
codes: List[str],
require_all: bool,
) -> bool:
"""Verifica permisos y, en desarrollo, aplica auto-bootstrap si el acceso falla.
Replica el bootstrap perezoso de ``core.security.validate_access_to_resource``:
en ``development`` el usuario (incluido el dev local) obtiene el rol super_admin
con todos los permisos la primera vez que lo necesita, para no bloquear el
entorno de desarrollo al activar el enforcement de permisos por área/carril.
"""
check = permission_service.has_all_permissions if require_all else permission_service.has_any_permission
if check(user_id=user_id, company_id=company_id, permission_codes=codes):
return True
if settings.ENVIRONMENT == "development":
try:
permission_service.bootstrap_super_admin(user_id, company_id)
if check(user_id=user_id, company_id=company_id, permission_codes=codes):
logger.info("Auto-bootstrap de permisos en dev: user_id=%s company_id=%s", user_id, company_id)
return True
except Exception as exc: # el bootstrap nunca debe escalar como acceso concedido
logger.warning("Auto-bootstrap de permisos falló: %s", exc)
return False
# Clase para verificación de permisos (puede usarse como dependencia)
class PermissionChecker:
"""
@@ -61,21 +94,8 @@ class PermissionChecker:
detail="User ID not found in token",
)
# Verificar permisos sobre la compañía
if self.require_all:
has_access = permission_service.has_all_permissions(
user_id=user_id,
company_id=company_id,
permission_codes=self.required_permissions,
)
else:
has_access = permission_service.has_any_permission(
user_id=user_id,
company_id=company_id,
permission_codes=self.required_permissions,
)
if not has_access:
# Verificar permisos sobre la compañía (con auto-bootstrap en desarrollo)
if not _authorize(permission_service, user_id, company_id, self.required_permissions, self.require_all):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {', '.join(self.required_permissions)}",
@@ -114,13 +134,7 @@ class RequirePermission:
detail="User ID not found in token",
)
has_permission = permission_service.has_permission(
user_id=user_id,
company_id=company_id,
permission_code=self.permission_code,
)
if not has_permission:
if not _authorize(permission_service, user_id, company_id, [self.permission_code], require_all=True):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permission: {self.permission_code}",

View File

@@ -0,0 +1,27 @@
"""Registro de permisos base del núcleo (core).
``PermissionService.sync_permissions`` y ``bootstrap_super_admin`` importan este
módulo por su efecto secundario: dar de alta en el ``PermissionRegistry`` los
permisos transversales del sistema antes de sincronizarlos a la base de datos.
Los permisos de cada dominio (crm, ops, fin) se registran en el
``register_permissions()`` de su propio módulo al importar sus routers.
"""
from .registry import registry
MODULE = "core"
def register_core_permissions() -> None:
"""Da de alta los permisos base del sistema (idempotente)."""
registry.register(code="core.access", description="Acceso al sistema", module=MODULE, action="access")
registry.register(code="core.admin", description="Administración del sistema", module=MODULE, action="admin")
registry.register(
code="core.permissions.manage",
description="Gestionar roles y permisos",
module=MODULE,
action="manage",
)
register_core_permissions()

View File

@@ -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")
)

View File

@@ -18,10 +18,12 @@ class AccountBase(BaseModel):
# Comercial
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
preferred_contact_other: str | None = Field(None, max_length=120)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
commercial_observations: str | None = None # observaciones generales
# Fiscal
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
@@ -59,10 +61,12 @@ class AccountUpdate(BaseModel):
status: str | None = Field(None, max_length=20)
commercial_classification: str | None = Field(None, max_length=20)
preferred_contact_method: str | None = Field(None, max_length=20)
preferred_contact_other: str | None = Field(None, max_length=120)
language: str | None = Field(None, max_length=40)
email: EmailStr | None = None
phone: str | None = Field(None, max_length=40)
website: str | None = Field(None, max_length=255)
commercial_observations: str | None = None
tax_regime: str | None = Field(None, max_length=120)
cfdi_use: str | None = Field(None, max_length=60)
payment_method: str | None = Field(None, max_length=60)

View File

@@ -39,12 +39,15 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
# ----- Información comercial -----
# Clasificación: importador | exportador | ambos
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
# Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Texto libre cuando el medio de contacto es "otro"
preferred_contact_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
# ----- Información fiscal -----
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal

View File

@@ -29,7 +29,8 @@ class Address(Base, TenantScopedMixin, TimestampMixin):
neighborhood: Mapped[str | None] = mapped_column(String(120), nullable=True) # colonia
postal_code: Mapped[str | None] = mapped_column(String(10), nullable=True) # código postal
city: Mapped[str | None] = mapped_column(String(120), nullable=True) # municipio
state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
state: Mapped[str | None] = mapped_column(String(120), nullable=True) # estado (código catálogo)
# País como código ISO 3166 alfa-3 del catálogo (p. ej. MEX). Ampliado de 2→3.
country: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'MEX'"))
reference_notes: Mapped[str | None] = mapped_column(Text, nullable=True) # referencias
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))

View File

@@ -0,0 +1,37 @@
"""Catálogos de referencia del dominio (Incoterms y actores/participantes).
Se centralizan aquí para administrarlos en un solo lugar y validarlos desde el
levantamiento de requerimientos (R-T-10) y modelar los participantes del proceso
(R-T-01), incluyendo la autoridad aduanera.
"""
# Incoterms 2020 (R-T-10)
INCOTERMS: list[dict] = [
{"code": "EXW", "name": "Ex Works — En fábrica"},
{"code": "FCA", "name": "Free Carrier — Franco transportista"},
{"code": "FAS", "name": "Free Alongside Ship — Franco al costado del buque"},
{"code": "FOB", "name": "Free On Board — Franco a bordo"},
{"code": "CFR", "name": "Cost and Freight — Costo y flete"},
{"code": "CIF", "name": "Cost, Insurance and Freight — Costo, seguro y flete"},
{"code": "CPT", "name": "Carriage Paid To — Transporte pagado hasta"},
{"code": "CIP", "name": "Carriage and Insurance Paid To — Transporte y seguro pagados hasta"},
{"code": "DAP", "name": "Delivered At Place — Entregado en lugar"},
{"code": "DPU", "name": "Delivered At Place Unloaded — Entregado en lugar descargado"},
{"code": "DDP", "name": "Delivered Duty Paid — Entregado con derechos pagados"},
]
INCOTERM_CODES: set[str] = {i["code"] for i in INCOTERMS}
# Roles/actores del proceso (R-T-01). Los actores externos se administran como
# proveedores (crm.suppliers) vía su clasificación; el cliente/prospecto como cuenta.
PARTICIPANT_ROLES: list[dict] = [
{"code": "exportador", "label": "Exportador", "source": "account"},
{"code": "importador", "label": "Importador", "source": "account"},
{"code": "agente_carga", "label": "Agente de carga", "source": "supplier"},
{"code": "agente_aduanal", "label": "Agente aduanal", "source": "supplier"},
{"code": "naviera", "label": "Naviera", "source": "supplier"},
{"code": "aerolinea", "label": "Aerolínea", "source": "supplier"},
{"code": "transportista_terrestre", "label": "Transportista terrestre", "source": "supplier"},
{"code": "agente_corresponsal", "label": "Agente corresponsal", "source": "supplier"},
{"code": "autoridad_aduanera", "label": "Autoridad aduanera", "source": "supplier"},
]

View File

@@ -0,0 +1,46 @@
"""Schemas (DTOs) de los catálogos de referencia del CRM."""
from pydantic import BaseModel, ConfigDict, Field
class CatalogItemBase(BaseModel):
code: str = Field(..., max_length=64)
label: str = Field(..., max_length=255)
parent_catalog: str | None = Field(None, max_length=60)
parent_code: str | None = Field(None, max_length=64)
sort_order: int = 0
is_active: bool = True
class CatalogItemCreate(CatalogItemBase):
pass
class CatalogItemUpdate(BaseModel):
"""PATCH: todos los campos opcionales."""
code: str | None = Field(None, max_length=64)
label: str | None = Field(None, max_length=255)
parent_code: str | None = Field(None, max_length=64)
sort_order: int | None = None
is_active: bool | None = None
class CatalogItemResponse(CatalogItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
catalog: str
tenant_id: int | None
is_system: bool
extra: dict | None = None # metadata (ej. dimensiones de un tipo de equipo)
class CatalogMeta(BaseModel):
"""Metadata de un catálogo para la pantalla de administración."""
catalog: str
label: str
scope: str # 'global' | 'tenant'
is_system: bool
count: int

View File

@@ -0,0 +1,54 @@
"""Modelo de catálogos de referencia del CRM (T2026-07-081/082).
Un único modelo genérico ``CatalogItem`` respalda todos los catálogos
(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo
(``catalog``) e identifica una opción por ``code`` (clave) + ``label``
(descripción que se visualiza).
Alcance:
- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos.
- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra).
Los catálogos dependientes (p. ej. Estado depende de País) usan
``parent_catalog`` + ``parent_code`` para filtrarse.
"""
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from core.database import Base
class CatalogItem(Base):
__tablename__ = "catalog_items"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(64), nullable=False)
label: Mapped[str] = mapped_column(String(255), nullable=False)
# Dependencia (Estado→País, Municipio→Estado, …)
parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True)
parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
# NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente).
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
# Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar).
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
extra: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)

View File

@@ -0,0 +1,167 @@
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).
Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales
(Aduanasoft) y catálogos propios de cada cliente (tenant).
"""
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from ..accounts.models import Account
from ..suppliers.models import Supplier
from . import service as catalog_service
from .data import INCOTERMS, PARTICIPANT_ROLES
from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta
router = APIRouter()
@router.get("/catalogs/incoterms")
def list_incoterms(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Catálogo de Incoterms 2020 (R-T-10)."""
return INCOTERMS
@router.get("/catalogs/participant-roles")
def list_participant_roles(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
"""Catálogo de roles/actores del proceso, incluida la autoridad aduanera (R-T-01)."""
return PARTICIPANT_ROLES
# ----------------------------------------------------------------------------
# Catálogos de referencia (CRUD) — T2026-07-081/082
# ----------------------------------------------------------------------------
@router.get("/catalogs", response_model=list[CatalogMeta])
def list_catalog_meta(
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Lista los catálogos disponibles (global + del tenant) con su conteo."""
return catalog_service.list_meta(db, current_user["tenant_id"])
@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse])
def list_catalog_items(
catalog: str,
company_id: int = Query(..., description="Company ID"),
parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"),
include_inactive: bool = Query(False),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Opciones de un catálogo (global + del tenant), activas y ordenadas."""
return catalog_service.list_items(
db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive
)
@router.post(
"/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED
)
def create_catalog_item(
catalog: str,
data: CatalogItemCreate,
company_id: int = Query(..., description="Company ID"),
scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Inserta una opción en un catálogo."""
return catalog_service.create_item(db, catalog, data, current_user, scope=scope)
@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse)
def update_catalog_item(
catalog: str,
item_id: int,
data: CatalogItemUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Edita una opción de catálogo."""
return catalog_service.update_item(db, catalog, item_id, data, current_user)
@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_catalog_item(
catalog: str,
item_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Borra una opción de catálogo (los catálogos base del sistema no se borran)."""
catalog_service.delete_item(db, catalog, item_id, current_user)
@router.get("/participants")
def list_participants(
company_id: int = Query(..., description="Company ID"),
role: str | None = Query(None, description="Filtra por rol/clasificación"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Vista unificada de participantes del proceso: clientes/prospectos (cuentas) y
actores externos (proveedores por clasificación), en un solo catálogo (R-T-01)."""
tenant_id = current_user["tenant_id"]
result: list[dict] = []
accounts = (
db.query(Account)
.filter(
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.all()
)
for acc in accounts:
acc_role = getattr(acc, "record_type", None) or "cliente"
if role and role not in (acc_role, "exportador", "importador"):
# Las cuentas representan exportador/importador/cliente; sólo se omiten
# cuando el filtro pide explícitamente un rol de proveedor.
if role not in ("exportador", "importador", "cliente", "prospecto"):
continue
result.append({
"id": acc.id,
"source": "account",
"name": acc.name,
"role": acc_role,
"roles": [acc_role],
})
suppliers = (
db.query(Supplier)
.filter(
Supplier.tenant_id == tenant_id,
Supplier.company_id == company_id,
Supplier.deleted_at.is_(None),
)
.all()
)
for sup in suppliers:
classifications = sup.classifications or []
if role and role not in classifications:
continue
result.append({
"id": sup.id,
"source": "supplier",
"name": sup.name,
"role": classifications[0] if classifications else "proveedor",
"roles": classifications,
})
return result

View File

@@ -0,0 +1,69 @@
"""Siembra de catálogos globales (Aduanasoft) del CRM.
Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se
puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno:
docker compose exec backend python -m api.v1.modules.crm.catalogs.seed
"""
import logging
from sqlalchemy.orm import Session
from .models import CatalogItem
from .seed_data import GLOBAL_CATALOGS
logger = logging.getLogger(__name__)
def seed_global_catalogs(db: Session) -> dict:
"""Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}."""
summary: dict[str, int] = {}
for catalog, meta in GLOBAL_CATALOGS.items():
is_system = bool(meta.get("is_system", False))
existing = {
row.code
for row in db.query(CatalogItem.code).filter(
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
)
}
added = 0
for order, item in enumerate(meta["items"]):
if item["code"] in existing:
continue
db.add(
CatalogItem(
catalog=catalog,
code=item["code"],
label=item["label"],
parent_catalog=item.get("parent_catalog"),
parent_code=item.get("parent_code"),
extra=item.get("extra"),
tenant_id=None,
sort_order=order,
is_active=True,
is_system=is_system,
)
)
added += 1
if added:
summary[catalog] = added
db.commit()
total = sum(summary.values())
logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary))
return summary
def _run() -> None:
from core.database import CoreSessionLocal
db = CoreSessionLocal()
try:
result = seed_global_catalogs(db)
print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)")
finally:
db.close()
if __name__ == "__main__":
_run()

View File

@@ -0,0 +1,850 @@
"""Datos semilla de los catálogos de referencia del CRM.
SAT/ISO + estándar + Medidas de Equipos (tipo_equipo con dimensiones en extra)
+ catálogos del módulo Tarifario. Globales con tenant_id NULL.
"""
GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro',
'is_system': True,
'items': [{'code': 'cliente', 'label': 'Cliente'}, {'code': 'prospecto', 'label': 'Prospecto'}]},
'tipo_persona': {'label': 'Tipo de persona',
'is_system': True,
'items': [{'code': 'fisica', 'label': 'Persona física'},
{'code': 'moral', 'label': 'Persona moral'}]},
'estatus': {'label': 'Estatus',
'is_system': True,
'items': [{'code': 'active', 'label': 'Activo'}, {'code': 'inactive', 'label': 'Inactivo'}]},
'giro': {'label': 'Giro o industria',
'is_system': False,
'items': [{'code': 'importadora', 'label': 'Importadora'},
{'code': 'exportadora', 'label': 'Exportadora'},
{'code': 'manufactura', 'label': 'Manufactura'},
{'code': 'comercializadora', 'label': 'Comercializadora'},
{'code': 'logistica', 'label': 'Logística y transporte'},
{'code': 'agencia_aduanal', 'label': 'Agencia aduanal'},
{'code': 'maquiladora', 'label': 'Maquiladora / IMMEX'},
{'code': 'servicios', 'label': 'Servicios'},
{'code': 'otro', 'label': 'Otro'}]},
'clasificacion_cliente': {'label': 'Clasificación del cliente',
'is_system': False,
'items': [{'code': 'importador', 'label': 'Importador'},
{'code': 'exportador', 'label': 'Exportador'},
{'code': 'importador_exportador', 'label': 'Importador/Exportador'}]},
'medio_contacto': {'label': 'Medio de contacto preferido',
'is_system': False,
'items': [{'code': 'llamada', 'label': 'Llamada telefónica'},
{'code': 'correo', 'label': 'Correo electrónico'},
{'code': 'videoconferencia', 'label': 'Videoconferencia'},
{'code': 'whatsapp', 'label': 'WhatsApp'},
{'code': 'otro', 'label': 'Otro'}]},
'idioma': {'label': 'Idioma',
'is_system': False,
'items': [{'code': 'es', 'label': 'Español'},
{'code': 'en', 'label': 'Inglés'},
{'code': 'zh', 'label': 'Chino (mandarín)'},
{'code': 'pt', 'label': 'Portugués'},
{'code': 'fr', 'label': 'Francés'},
{'code': 'de', 'label': 'Alemán'},
{'code': 'ja', 'label': 'Japonés'},
{'code': 'ko', 'label': 'Coreano'},
{'code': 'it', 'label': 'Italiano'},
{'code': 'otro', 'label': 'Otro'}]},
'regimen_fiscal': {'label': 'Régimen fiscal',
'is_system': False,
'items': [{'code': 'fisica', 'label': 'Persona física'},
{'code': 'moral', 'label': 'Persona moral'}]},
'uso_cfdi': {'label': 'Uso de CFDI (SAT)',
'is_system': True,
'items': [{'code': 'G01', 'label': 'Adquisición de mercancías'},
{'code': 'G02', 'label': 'Devoluciones, descuentos o bonificaciones'},
{'code': 'G03', 'label': 'Gastos en general'},
{'code': 'I01', 'label': 'Construcciones'},
{'code': 'I02', 'label': 'Mobiliario y equipo de oficina por inversiones'},
{'code': 'I03', 'label': 'Equipo de transporte'},
{'code': 'I04', 'label': 'Equipo de cómputo y accesorios'},
{'code': 'I05', 'label': 'Dados, troqueles, moldes, matrices y herramental'},
{'code': 'I06', 'label': 'Comunicaciones telefónicas'},
{'code': 'I07', 'label': 'Comunicaciones satelitales'},
{'code': 'I08', 'label': 'Otra maquinaria y equipo'},
{'code': 'D01', 'label': 'Honorarios médicos, dentales y gastos hospitalarios'},
{'code': 'D02', 'label': 'Gastos médicos por incapacidad o discapacidad'},
{'code': 'D03', 'label': 'Gastos funerales'},
{'code': 'D04', 'label': 'Donativos'},
{'code': 'D05', 'label': 'Intereses por créditos hipotecarios'},
{'code': 'D06', 'label': 'Aportaciones voluntarias al SAR'},
{'code': 'D07', 'label': 'Primas por seguros de gastos médicos'},
{'code': 'D08', 'label': 'Gastos de transportación escolar obligatoria'},
{'code': 'D09', 'label': 'Depósitos en cuentas para el ahorro'},
{'code': 'D10', 'label': 'Pagos por servicios educativos (colegiaturas)'},
{'code': 'S01', 'label': 'Sin efectos fiscales'},
{'code': 'CP01', 'label': 'Pagos'},
{'code': 'CN01', 'label': 'Nómina'},
{'code': 'P01', 'label': 'Por definir'}]},
'forma_pago': {'label': 'Forma de pago (SAT)',
'is_system': True,
'items': [{'code': '1', 'label': 'Efectivo'},
{'code': '2', 'label': 'Cheque nominativo'},
{'code': '3', 'label': 'Transferencia electrónica de fondos'},
{'code': '4', 'label': 'Tarjeta de crédito'},
{'code': '5', 'label': 'Monedero electrónico'},
{'code': '6', 'label': 'Dinero electrónico'},
{'code': '8', 'label': 'Vales de despensa'},
{'code': '12', 'label': 'Dación en pago'},
{'code': '13', 'label': 'Pago por subrogación'},
{'code': '14', 'label': 'Pago por consignación'},
{'code': '15', 'label': 'Condonación'},
{'code': '17', 'label': 'Compensación'},
{'code': '23', 'label': 'Novación'},
{'code': '24', 'label': 'Confusión'},
{'code': '25', 'label': 'Remisión de deuda'},
{'code': '26', 'label': 'Prescripción o caducidad'},
{'code': '27', 'label': 'A satisfacción del acreedor'},
{'code': '28', 'label': 'Tarjeta de débito'},
{'code': '29', 'label': 'Tarjeta de servicios'},
{'code': '30', 'label': 'Aplicación de anticipos'},
{'code': '31', 'label': 'Intermediario pagos'},
{'code': '99', 'label': 'Por definir'}]},
'metodo_pago': {'label': 'Método de pago (SAT)',
'is_system': True,
'items': [{'code': 'PPD', 'label': 'Pago en parcialidades o diferido'},
{'code': 'PUE', 'label': 'Pago en una sola exhibición'}]},
'moneda': {'label': 'Moneda (ISO 4217)',
'is_system': True,
'items': [{'code': 'CRC', 'label': 'Colón costarricense'},
{'code': 'CUC', 'label': 'Peso Convertible'},
{'code': 'CUP', 'label': 'Peso Cubano'},
{'code': 'CVE', 'label': 'Cabo Verde Escudo'},
{'code': 'CZK', 'label': 'Corona checa'},
{'code': 'DJF', 'label': 'Franco de Djibouti'},
{'code': 'DKK', 'label': 'Corona danesa'},
{'code': 'DOP', 'label': 'Peso Dominicano'},
{'code': 'DZD', 'label': 'Dinar argelino'},
{'code': 'EGP', 'label': 'Libra egipcia'},
{'code': 'ERN', 'label': 'Nakfa'},
{'code': 'ETB', 'label': 'Birr etíope'},
{'code': 'EUR', 'label': 'Euro'},
{'code': 'FJD', 'label': 'Dólar de Fiji'},
{'code': 'FKP', 'label': 'Libra malvinense'},
{'code': 'GBP', 'label': 'Libra Esterlina'},
{'code': 'GEL', 'label': 'Lari'},
{'code': 'GHS', 'label': 'Cedi de Ghana'},
{'code': 'GIP', 'label': 'Libra de Gibraltar'},
{'code': 'GMD', 'label': 'Dalasi'},
{'code': 'GNF', 'label': 'Franco guineano'},
{'code': 'GTQ', 'label': 'Quetzal'},
{'code': 'GYD', 'label': 'Dólar guyanés'},
{'code': 'HKD', 'label': 'Dolar De Hong Kong'},
{'code': 'HNL', 'label': 'Lempira'},
{'code': 'HRK', 'label': 'Kuna'},
{'code': 'HTG', 'label': 'Gourde'},
{'code': 'HUF', 'label': 'Florín'},
{'code': 'IDR', 'label': 'Rupia'},
{'code': 'ILS', 'label': 'Nuevo Shekel Israelí'},
{'code': 'INR', 'label': 'Rupia india'},
{'code': 'IQD', 'label': 'Dinar iraquí'},
{'code': 'IRR', 'label': 'Rial iraní'},
{'code': 'ISK', 'label': 'Corona islandesa'},
{'code': 'JMD', 'label': 'Dólar Jamaiquino'},
{'code': 'JOD', 'label': 'Dinar jordano'},
{'code': 'JPY', 'label': 'Yen'},
{'code': 'KES', 'label': 'Chelín keniano'},
{'code': 'KGS', 'label': 'Som'},
{'code': 'KHR', 'label': 'Riel'},
{'code': 'KMF', 'label': 'Franco Comoro'},
{'code': 'KPW', 'label': 'Corea del Norte ganó'},
{'code': 'KRW', 'label': 'Won'},
{'code': 'KWD', 'label': 'Dinar kuwaití'},
{'code': 'KYD', 'label': 'Dólar de las Islas Caimán'},
{'code': 'KZT', 'label': 'Tenge'},
{'code': 'LAK', 'label': 'Kip'},
{'code': 'LBP', 'label': 'Libra libanesa'},
{'code': 'LKR', 'label': 'Rupia de Sri Lanka'},
{'code': 'LRD', 'label': 'Dólar liberiano'},
{'code': 'LSL', 'label': 'Loti'},
{'code': 'LYD', 'label': 'Dinar libio'},
{'code': 'MAD', 'label': 'Dirham marroquí'},
{'code': 'MDL', 'label': 'Leu moldavo'},
{'code': 'MGA', 'label': 'Ariary malgache'},
{'code': 'MKD', 'label': 'Denar'},
{'code': 'MMK', 'label': 'Kyat'},
{'code': 'MNT', 'label': 'Tugrik'},
{'code': 'MOP', 'label': 'Pataca'},
{'code': 'MRO', 'label': 'Ouguiya'},
{'code': 'MUR', 'label': 'Rupia de Mauricio'},
{'code': 'MVR', 'label': 'Rupia'},
{'code': 'MWK', 'label': 'Kwacha'},
{'code': 'MXN', 'label': 'Peso Mexicano'},
{'code': 'MXV', 'label': 'México Unidad de Inversión (UDI)'},
{'code': 'MYR', 'label': 'Ringgit malayo'},
{'code': 'MZN', 'label': 'Mozambique Metical'},
{'code': 'NAD', 'label': 'Dólar de Namibia'},
{'code': 'NGN', 'label': 'Naira'},
{'code': 'NIO', 'label': 'Córdoba Oro'},
{'code': 'NOK', 'label': 'Corona noruega'},
{'code': 'NPR', 'label': 'Rupia nepalí'},
{'code': 'NZD', 'label': 'Dólar de Nueva Zelanda'},
{'code': 'OMR', 'label': 'Rial omaní'},
{'code': 'PAB', 'label': 'Balboa'},
{'code': 'PEN', 'label': 'Nuevo Sol'},
{'code': 'PGK', 'label': 'Kina'},
{'code': 'PHP', 'label': 'Peso filipino'},
{'code': 'PKR', 'label': 'Rupia de Pakistán'},
{'code': 'PLN', 'label': 'Zloty'},
{'code': 'PYG', 'label': 'Guaraní'},
{'code': 'QAR', 'label': 'Qatar Rial'},
{'code': 'RON', 'label': 'Leu rumano'},
{'code': 'RSD', 'label': 'Dinar serbio'},
{'code': 'RUB', 'label': 'Rublo ruso'},
{'code': 'RWF', 'label': 'Franco ruandés'},
{'code': 'SAR', 'label': 'Riyal saudí'},
{'code': 'SBD', 'label': 'Dólar de las Islas Salomón'},
{'code': 'SCR', 'label': 'Rupia de Seychelles'},
{'code': 'SDG', 'label': 'Libra sudanesa'},
{'code': 'SEK', 'label': 'Corona sueca'},
{'code': 'SGD', 'label': 'Dolar De Singapur'},
{'code': 'SHP', 'label': 'Libra de Santa Helena'},
{'code': 'SLL', 'label': 'Leona'},
{'code': 'SOS', 'label': 'Chelín somalí'},
{'code': 'SRD', 'label': 'Dólar de Suriname'},
{'code': 'SSP', 'label': 'Libra sudanesa Sur'},
{'code': 'STD', 'label': 'Dobra'},
{'code': 'SVC', 'label': 'Colon El Salvador'},
{'code': 'SYP', 'label': 'Libra Siria'},
{'code': 'SZL', 'label': 'Lilangeni'},
{'code': 'THB', 'label': 'Baht'},
{'code': 'TJS', 'label': 'Somoni'},
{'code': 'TMT', 'label': 'Turkmenistán nuevo manat'},
{'code': 'TND', 'label': 'Dinar tunecino'},
{'code': 'TOP', 'label': "Pa'anga"},
{'code': 'TRY', 'label': 'Lira turca'},
{'code': 'TTD', 'label': 'Dólar de Trinidad y Tobago'},
{'code': 'TWD', 'label': 'Nuevo dólar de Taiwán'},
{'code': 'TZS', 'label': 'Shilling tanzano'},
{'code': 'UAH', 'label': 'Hryvnia'},
{'code': 'UGX', 'label': 'Shilling de Uganda'},
{'code': 'USD', 'label': 'Dolar americano'},
{'code': 'USN', 'label': 'Dólar estadounidense (día siguiente)'},
{'code': 'UYI', 'label': 'Peso Uruguay en Unidades Indexadas (URUIURUI)'},
{'code': 'UYU', 'label': 'Peso Uruguayo'},
{'code': 'UZS', 'label': 'Uzbekistán Sum'},
{'code': 'VEF', 'label': 'Bolívar'},
{'code': 'VND', 'label': 'Dong'},
{'code': 'VUV', 'label': 'Vatu'},
{'code': 'WST', 'label': 'Tala'},
{'code': 'XAF', 'label': 'Franco CFA BEAC'},
{'code': 'XAG', 'label': 'Plata'},
{'code': 'XAU', 'label': 'Oro'},
{'code': 'XBA', 'label': 'Unidad de Mercados de Bonos Unidad Europea Composite (EURCO)'},
{'code': 'XBB', 'label': 'Unidad Monetaria de Bonos de Mercados Unidad Europea (UEM-6)'},
{'code': 'XBC', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 9 (UCE-9)'},
{'code': 'XBD', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 17 (UCE-17)'},
{'code': 'XCD', 'label': 'Dólar del Caribe Oriental'},
{'code': 'XDR', 'label': 'DEG (Derechos Especiales de Giro)'},
{'code': 'XOF', 'label': 'Franco CFA BCEAO'},
{'code': 'XPD', 'label': 'Paladio'},
{'code': 'XPF', 'label': 'Franco CFP'},
{'code': 'XPT', 'label': 'Platino'},
{'code': 'XSU', 'label': 'Sucre'},
{'code': 'XTS', 'label': 'Códigos reservados específicamente para propósitos de prueba'},
{'code': 'XUA', 'label': 'Unidad ADB de Cuenta'},
{'code': 'XXX',
'label': 'Los códigos asignados para las transacciones en que intervenga ninguna moneda'},
{'code': 'YER', 'label': 'Rial yemení'},
{'code': 'ZAR', 'label': 'Rand'},
{'code': 'ZMW', 'label': 'Kwacha zambiano'},
{'code': 'ZWL', 'label': 'Zimbabwe Dólar'},
{'code': 'NULL', 'label': 'NULL'}]},
'pais': {'label': 'País (ISO 3166)',
'is_system': True,
'items': [{'code': 'ABW', 'label': 'Aruba'},
{'code': 'AFG', 'label': 'Afganistán'},
{'code': 'AGO', 'label': 'Angola'},
{'code': 'AIA', 'label': 'Anguila'},
{'code': 'ALA', 'label': 'Islas Åland'},
{'code': 'ALB', 'label': 'Albania'},
{'code': 'AND', 'label': 'Andorra'},
{'code': 'ARE', 'label': 'Emiratos Árabes Unidos (Los)'},
{'code': 'ARG', 'label': 'Argentina'},
{'code': 'ARM', 'label': 'Armenia'},
{'code': 'ASM', 'label': 'Samoa Americana'},
{'code': 'ATA', 'label': 'Antártida'},
{'code': 'ATF', 'label': 'Territorios Australes Franceses (los)'},
{'code': 'ATG', 'label': 'Antigua y Barbuda'},
{'code': 'AUS', 'label': 'Australia'},
{'code': 'AUT', 'label': 'Austria'},
{'code': 'AZE', 'label': 'Azerbaiyán'},
{'code': 'BDI', 'label': 'Burundi'},
{'code': 'BEL', 'label': 'Bélgica'},
{'code': 'BEN', 'label': 'Benín'},
{'code': 'BES', 'label': 'Bonaire, San Eustaquio y Saba'},
{'code': 'BFA', 'label': 'Burkina Faso'},
{'code': 'BGD', 'label': 'Bangladés'},
{'code': 'BGR', 'label': 'Bulgaria'},
{'code': 'BHR', 'label': 'Baréin'},
{'code': 'BHS', 'label': 'Bahamas (las)'},
{'code': 'BIH', 'label': 'Bosnia y Herzegovina'},
{'code': 'BLM', 'label': 'San Bartolomé'},
{'code': 'BLR', 'label': 'Bielorrusia'},
{'code': 'BLZ', 'label': 'Belice'},
{'code': 'BMU', 'label': 'Bermudas'},
{'code': 'BOL', 'label': 'Bolivia, Estado Plurinacional de'},
{'code': 'BRA', 'label': 'Brasil'},
{'code': 'BRB', 'label': 'Barbados'},
{'code': 'BRN', 'label': 'Brunéi Darussalam'},
{'code': 'BTN', 'label': 'Bután'},
{'code': 'BVT', 'label': 'Isla Bouvet'},
{'code': 'BWA', 'label': 'Botsuana'},
{'code': 'CAF', 'label': 'República Centroafricana (la)'},
{'code': 'CAN', 'label': 'Canadá'},
{'code': 'CCK', 'label': 'Islas Cocos (Keeling)'},
{'code': 'CHE', 'label': 'Suiza'},
{'code': 'CHL', 'label': 'Chile'},
{'code': 'CHN', 'label': 'China'},
{'code': 'CIV', 'label': "Côte d'Ivoire"},
{'code': 'CMR', 'label': 'Camerún'},
{'code': 'COD', 'label': 'Congo (la República Democrática del)'},
{'code': 'COG', 'label': 'Congo'},
{'code': 'COK', 'label': 'Islas Cook (las)'},
{'code': 'COL', 'label': 'Colombia'},
{'code': 'COM', 'label': 'Comoras'},
{'code': 'CPV', 'label': 'Cabo Verde'},
{'code': 'CRI', 'label': 'Costa Rica'},
{'code': 'CUB', 'label': 'Cuba'},
{'code': 'CUW', 'label': 'Curaçao'},
{'code': 'CXR', 'label': 'Isla de Navidad'},
{'code': 'CYM', 'label': 'Islas Caimán (las)'},
{'code': 'CYP', 'label': 'Chipre'},
{'code': 'CZE', 'label': 'República Checa (la)'},
{'code': 'DEU', 'label': 'Alemania'},
{'code': 'DJI', 'label': 'Yibuti'},
{'code': 'DMA', 'label': 'Dominica'},
{'code': 'DNK', 'label': 'Dinamarca'},
{'code': 'DOM', 'label': 'República Dominicana (la)'},
{'code': 'DZA', 'label': 'Argelia'},
{'code': 'ECU', 'label': 'Ecuador'},
{'code': 'EGY', 'label': 'Egipto'},
{'code': 'ERI', 'label': 'Eritrea'},
{'code': 'ESH', 'label': 'Sahara Occidental'},
{'code': 'ESP', 'label': 'España'},
{'code': 'EST', 'label': 'Estonia'},
{'code': 'ETH', 'label': 'Etiopía'},
{'code': 'FIN', 'label': 'Finlandia'},
{'code': 'FJI', 'label': 'Fiyi'},
{'code': 'FLK', 'label': 'Islas Malvinas [Falkland] (las)'},
{'code': 'FRA', 'label': 'Francia'},
{'code': 'FRO', 'label': 'Islas Feroe (las)'},
{'code': 'FSM', 'label': 'Micronesia (los Estados Federados de)'},
{'code': 'GAB', 'label': 'Gabón'},
{'code': 'GBR', 'label': 'Reino Unido (el)'},
{'code': 'GEO', 'label': 'Georgia'},
{'code': 'GGY', 'label': 'Guernsey'},
{'code': 'GHA', 'label': 'Ghana'},
{'code': 'GIB', 'label': 'Gibraltar'},
{'code': 'GIN', 'label': 'Guinea'},
{'code': 'GLP', 'label': 'Guadalupe'},
{'code': 'GMB', 'label': 'Gambia (La)'},
{'code': 'GNB', 'label': 'Guinea-Bisáu'},
{'code': 'GNQ', 'label': 'Guinea Ecuatorial'},
{'code': 'GRC', 'label': 'Grecia'},
{'code': 'GRD', 'label': 'Granada'},
{'code': 'GRL', 'label': 'Groenlandia'},
{'code': 'GTM', 'label': 'Guatemala'},
{'code': 'GUF', 'label': 'Guayana Francesa'},
{'code': 'GUM', 'label': 'Guam'},
{'code': 'GUY', 'label': 'Guyana'},
{'code': 'HKG', 'label': 'Hong Kong'},
{'code': 'HMD', 'label': 'Isla Heard e Islas McDonald'},
{'code': 'HND', 'label': 'Honduras'},
{'code': 'HRV', 'label': 'Croacia'},
{'code': 'HTI', 'label': 'Haití'},
{'code': 'HUN', 'label': 'Hungría'},
{'code': 'IDN', 'label': 'Indonesia'},
{'code': 'IMN', 'label': 'Isla de Man'},
{'code': 'IND', 'label': 'India'},
{'code': 'IOT', 'label': 'Territorio Británico del Océano Índico (el)'},
{'code': 'IRL', 'label': 'Irlanda'},
{'code': 'IRN', 'label': 'Irán (la República Islámica de)'},
{'code': 'IRQ', 'label': 'Irak'},
{'code': 'ISL', 'label': 'Islandia'},
{'code': 'ISR', 'label': 'Israel'},
{'code': 'ITA', 'label': 'Italia'},
{'code': 'JAM', 'label': 'Jamaica'},
{'code': 'JEY', 'label': 'Jersey'},
{'code': 'JOR', 'label': 'Jordania'},
{'code': 'JPN', 'label': 'Japón'},
{'code': 'KAZ', 'label': 'Kazajistán'},
{'code': 'KEN', 'label': 'Kenia'},
{'code': 'KGZ', 'label': 'Kirguistán'},
{'code': 'KHM', 'label': 'Camboya'},
{'code': 'KIR', 'label': 'Kiribati'},
{'code': 'KNA', 'label': 'San Cristóbal y Nieves'},
{'code': 'KOR', 'label': 'Corea (la República de)'},
{'code': 'KWT', 'label': 'Kuwait'},
{'code': 'LAO', 'label': 'Lao, (la) República Democrática Popular'},
{'code': 'LBN', 'label': 'Líbano'},
{'code': 'LBR', 'label': 'Liberia'},
{'code': 'LBY', 'label': 'Libia'},
{'code': 'LCA', 'label': 'Santa Lucía'},
{'code': 'LIE', 'label': 'Liechtenstein'},
{'code': 'LKA', 'label': 'Sri Lanka'},
{'code': 'LSO', 'label': 'Lesoto'},
{'code': 'LTU', 'label': 'Lituania'},
{'code': 'LUX', 'label': 'Luxemburgo'},
{'code': 'LVA', 'label': 'Letonia'},
{'code': 'MAC', 'label': 'Macao'},
{'code': 'MAF', 'label': 'San Martín (parte francesa)'},
{'code': 'MAR', 'label': 'Marruecos'},
{'code': 'MCO', 'label': 'Mónaco'},
{'code': 'MDA', 'label': 'Moldavia (la República de)'},
{'code': 'MDG', 'label': 'Madagascar'},
{'code': 'MDV', 'label': 'Maldivas'},
{'code': 'MEX', 'label': 'México'},
{'code': 'MHL', 'label': 'Islas Marshall (las)'},
{'code': 'MKD', 'label': 'Macedonia (la antigua República Yugoslava de)'},
{'code': 'MLI', 'label': 'Malí'},
{'code': 'MLT', 'label': 'Malta'},
{'code': 'MMR', 'label': 'Myanmar'},
{'code': 'MNE', 'label': 'Montenegro'},
{'code': 'MNG', 'label': 'Mongolia'},
{'code': 'MNP', 'label': 'Islas Marianas del Norte (las)'},
{'code': 'MOZ', 'label': 'Mozambique'},
{'code': 'MRT', 'label': 'Mauritania'},
{'code': 'MSR', 'label': 'Montserrat'},
{'code': 'MTQ', 'label': 'Martinica'},
{'code': 'MUS', 'label': 'Mauricio'},
{'code': 'MWI', 'label': 'Malaui'},
{'code': 'MYS', 'label': 'Malasia'},
{'code': 'MYT', 'label': 'Mayotte'},
{'code': 'NAM', 'label': 'Namibia'},
{'code': 'NCL', 'label': 'Nueva Caledonia'},
{'code': 'NER', 'label': 'Níger (el)'},
{'code': 'NFK', 'label': 'Isla Norfolk'},
{'code': 'NGA', 'label': 'Nigeria'},
{'code': 'NIC', 'label': 'Nicaragua'},
{'code': 'NIU', 'label': 'Niue'},
{'code': 'NLD', 'label': 'Países Bajos (los)'},
{'code': 'NOR', 'label': 'Noruega'},
{'code': 'NPL', 'label': 'Nepal'},
{'code': 'NRU', 'label': 'Nauru'},
{'code': 'NZL', 'label': 'Nueva Zelanda'},
{'code': 'OMN', 'label': 'Omán'},
{'code': 'PAK', 'label': 'Pakistán'},
{'code': 'PAN', 'label': 'Panamá'},
{'code': 'PCN', 'label': 'Pitcairn'},
{'code': 'PER', 'label': 'Perú'},
{'code': 'PHL', 'label': 'Filipinas (las)'},
{'code': 'PLW', 'label': 'Palaos'},
{'code': 'PNG', 'label': 'Papúa Nueva Guinea'},
{'code': 'POL', 'label': 'Polonia'},
{'code': 'PRI', 'label': 'Puerto Rico'},
{'code': 'PRK', 'label': 'Corea (la República Democrática Popular de)'},
{'code': 'PRT', 'label': 'Portugal'},
{'code': 'PRY', 'label': 'Paraguay'},
{'code': 'PSE', 'label': 'Palestina, Estado de'},
{'code': 'PYF', 'label': 'Polinesia Francesa'},
{'code': 'QAT', 'label': 'Catar'},
{'code': 'REU', 'label': 'Reunión'},
{'code': 'ROU', 'label': 'Rumania'},
{'code': 'RUS', 'label': 'Rusia, (la) Federación de'},
{'code': 'RWA', 'label': 'Ruanda'},
{'code': 'SAU', 'label': 'Arabia Saudita'},
{'code': 'SDN', 'label': 'Sudán (el)'},
{'code': 'SEN', 'label': 'Senegal'},
{'code': 'SGP', 'label': 'Singapur'},
{'code': 'SGS', 'label': 'Georgia del sur y las islas sandwich del sur'},
{'code': 'SHN', 'label': 'Santa Helena, Ascensión y Tristán de Acuña'},
{'code': 'SJM', 'label': 'Svalbard y Jan Mayen'},
{'code': 'SLB', 'label': 'Islas Salomón (las)'},
{'code': 'SLE', 'label': 'Sierra leona'},
{'code': 'NULL', 'label': 'NULL'}]},
'estado': {'label': 'Estado / Provincia',
'is_system': True,
'items': [{'code': 'AGU', 'label': 'Aguascalientes', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'BCN', 'label': 'Baja California', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'BCS', 'label': 'Baja California Sur', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CAM', 'label': 'Campeche', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CHP', 'label': 'Chiapas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CHH', 'label': 'Chihuahua', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'CMX', 'label': 'Ciudad de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'COA', 'label': 'Coahuila', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'COL', 'label': 'Colima', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'DUR', 'label': 'Durango', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'GUA', 'label': 'Guanajuato', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'GRO', 'label': 'Guerrero', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'HID', 'label': 'Hidalgo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'JAL', 'label': 'Jalisco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'MEX', 'label': 'Estado de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'MIC', 'label': 'Michoacán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'MOR', 'label': 'Morelos', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'NAY', 'label': 'Nayarit', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'NLE', 'label': 'Nuevo León', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'OAX', 'label': 'Oaxaca', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'PUE', 'label': 'Puebla', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'QUE', 'label': 'Querétaro', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'ROO', 'label': 'Quintana Roo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'SLP', 'label': 'San Luis Potosí', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'SIN', 'label': 'Sinaloa', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'SON', 'label': 'Sonora', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'TAB', 'label': 'Tabasco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'TAM', 'label': 'Tamaulipas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'TLA', 'label': 'Tlaxcala', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'VER', 'label': 'Veracruz', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'YUC', 'label': 'Yucatán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
{'code': 'ZAC', 'label': 'Zacatecas', 'parent_catalog': 'pais', 'parent_code': 'MEX'}]},
'tipo_domicilio': {'label': 'Tipo de domicilio',
'is_system': False,
'items': [{'code': 'fiscal', 'label': 'Fiscal'},
{'code': 'oficina', 'label': 'Oficina'},
{'code': 'sucursal', 'label': 'Sucursal'},
{'code': 'bodega', 'label': 'Bodega'},
{'code': 'patio', 'label': 'Patio'},
{'code': 'terminal', 'label': 'Terminal'},
{'code': 'almacen', 'label': 'Almacén'}]},
'area': {'label': 'Área / Departamento',
'is_system': False,
'items': [{'code': 'ventas', 'label': 'Ventas'},
{'code': 'operaciones', 'label': 'Operaciones'},
{'code': 'facturacion', 'label': 'Facturación'},
{'code': 'cobranza', 'label': 'Cobranza'},
{'code': 'servicio_cliente', 'label': 'Servicio al cliente'}]},
'cobertura': {'label': 'Cobertura',
'is_system': True,
'items': [{'code': 'nacional', 'label': 'Nacional'},
{'code': 'internacional', 'label': 'Internacional'}]},
'clasificacion_proveedor': {'label': 'Clasificación del proveedor',
'is_system': False,
'items': [{'code': 'naviera', 'label': 'Naviera'},
{'code': 'aerolinea', 'label': 'Aerolínea'},
{'code': 'transportista_terrestre', 'label': 'Transportista Terrestre'},
{'code': 'ferrocarril', 'label': 'Ferrocarril'},
{'code': 'agente_aduanal', 'label': 'Agente Aduanal'},
{'code': 'agente_carga', 'label': 'Agente de Carga'},
{'code': 'agente_corresponsal', 'label': 'Agente Corresponsal'},
{'code': 'almacen', 'label': 'Almacén'},
{'code': 'aseguradora', 'label': 'Aseguradora'},
{'code': 'paqueteria', 'label': 'Paquetería'},
{'code': 'otro', 'label': 'Otro'}]},
'tipo_equipo': {'label': 'Tipo de equipo / contenedor',
'is_system': True,
'items': [{'code': '40DC',
'label': "40' Standard",
'extra': {'modo': 'maritimo',
'largo_m': 12.035,
'ancho_m': 2.35,
'alto_m': 2.392,
'capacidad_m3': 67.7,
'tara_kg': 3700,
'carga_max_kg': 26790}},
{'code': '20DC',
'label': "20' Standard",
'extra': {'modo': 'maritimo',
'largo_m': 5.9,
'ancho_m': 2.35,
'alto_m': 2.392,
'capacidad_m3': 33.2,
'tara_kg': 2230,
'carga_max_kg': 21770}},
{'code': '20OT',
'label': "20' Open Top",
'extra': {'modo': 'maritimo',
'largo_m': 5.894,
'ancho_m': 2.311,
'alto_m': 2.354,
'capacidad_m3': 32.23,
'tara_kg': 2400,
'carga_max_kg': 30490}},
{'code': '20FR',
'label': "20' Flat Rack",
'extra': {'modo': 'maritimo',
'largo_m': 5.62,
'ancho_m': 2.23,
'alto_m': 2.233,
'tara_kg': 2530,
'carga_max_kg': 21470}},
{'code': '40HC',
'label': "40' High Cube",
'extra': {'modo': 'maritimo',
'largo_m': 12.036,
'ancho_m': 2.35,
'alto_m': 2.697,
'capacidad_m3': 76.3,
'tara_kg': 3970,
'carga_max_kg': 26510}},
{'code': '20PL',
'label': "20' Platform",
'extra': {'modo': 'maritimo',
'largo_m': 6.058,
'ancho_m': 2.438,
'alto_m': 0.37,
'tara_kg': 2520,
'carga_max_kg': 27960}},
{'code': '20FRC',
'label': "20' Flat Rack Collapsible",
'extra': {'modo': 'maritimo',
'largo_m': 5.618,
'ancho_m': 2.206,
'alto_m': 2.233,
'tara_kg': 2750,
'carga_max_kg': 27730}},
{'code': '20BK',
'label': "20' Bulk",
'extra': {'modo': 'maritimo',
'largo_m': 5.93,
'ancho_m': 2.35,
'alto_m': 2.34,
'capacidad_m3': 32.0,
'tara_kg': 2450,
'carga_max_kg': 21350}},
{'code': '20TK',
'label': "20' Tank",
'extra': {'modo': 'maritimo',
'largo_m': 6.058,
'ancho_m': 2.438,
'alto_m': 2.438,
'tara_kg': 4100,
'carga_max_kg': 26200}},
{'code': 'LD2',
'label': 'LD2',
'extra': {'modo': 'aereo',
'capacidad_m3': 3.5,
'tara_kg': 30,
'carga_max_kg': 1225,
'nota': 'Aviones 767'}},
{'code': 'LD3',
'label': 'LD3',
'extra': {'modo': 'aereo',
'capacidad_m3': 4.2,
'tara_kg': 80,
'carga_max_kg': 1587,
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
{'code': 'LBD',
'label': 'LBD (Flex Door)',
'extra': {'modo': 'aereo',
'capacidad_m3': 7.0,
'tara_kg': 123,
'carga_max_kg': 2449,
'nota': 'Aviones 767'}},
{'code': 'LD6',
'label': 'LD6',
'extra': {'modo': 'aereo',
'capacidad_m3': 8.9,
'tara_kg': 175,
'carga_max_kg': 3175,
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
{'code': 'PAG',
'label': 'PAP / PIP / PAG',
'extra': {'modo': 'aereo',
'capacidad_m3': 10.0,
'tara_kg': 120,
'carga_max_kg': 6033,
'nota': 'Boeing 747/767/777/DC10'}},
{'code': 'LD9',
'label': 'LD9 AAP',
'extra': {'modo': 'aereo',
'capacidad_m3': 10.0,
'tara_kg': 85,
'carga_max_kg': 1588,
'nota': 'Boeing 747/777/DC10'}},
{'code': 'XAW',
'label': 'XAW',
'extra': {'modo': 'aereo',
'capacidad_m3': 14.0,
'tara_kg': 170,
'carga_max_kg': 5000,
'nota': 'Boeing 747/777/DC10'}},
{'code': 'PMC',
'label': 'PMC',
'extra': {'modo': 'aereo',
'capacidad_m3': 12.7,
'tara_kg': 130,
'carga_max_kg': 6804,
'nota': 'Boeing 747/767/777'}},
{'code': 'LD8',
'label': 'LD8',
'extra': {'modo': 'aereo', 'capacidad_m3': 7.2, 'tara_kg': 120, 'carga_max_kg': 2450}},
{'code': 'DV48',
'label': "Dry Van 48'",
'extra': {'modo': 'terrestre',
'largo_m': 14.63,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 98.0,
'carga_max_kg': 20412,
'pallets': 22}},
{'code': 'SD',
'label': 'Legal Step Deck (Single Drop)',
'extra': {'modo': 'terrestre',
'largo_m': 11.58,
'ancho_m': 2.59,
'alto_m': 3.05,
'carga_max_kg': 20865}},
{'code': 'TANK',
'label': 'Tanker',
'extra': {'modo': 'terrestre',
'largo_m': 16.15,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_l': 22712}},
{'code': 'DV53',
'label': "Dry Van 53'",
'extra': {'modo': 'terrestre',
'largo_m': 16.15,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 99.11,
'carga_max_kg': 20412,
'pallets': 26}},
{'code': 'DD',
'label': 'Double Drop (Low Boy)',
'extra': {'modo': 'terrestre',
'largo_m': 8.53,
'ancho_m': 2.59,
'alto_m': 3.51,
'carga_max_kg': 18144}},
{'code': 'RF48',
'label': "48' Reefer Trailer",
'extra': {'modo': 'terrestre',
'largo_m': 14.63,
'ancho_m': 2.4,
'alto_m': 2.3,
'capacidad_m3': 90.0,
'carga_max_kg': 19958,
'pallets': 20}},
{'code': 'FB48',
'label': "48' Legal Flatbed",
'extra': {'modo': 'terrestre',
'largo_m': 14.63,
'ancho_m': 2.59,
'alto_m': 2.59,
'carga_max_kg': 21772}},
{'code': 'PUP28',
'label': "Pup Trailer 28'",
'extra': {'modo': 'terrestre',
'largo_m': 8.53,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 57.45,
'carga_max_kg': 9979,
'pallets': 14}},
{'code': 'IM53',
'label': "Intermodal 53' Container",
'extra': {'modo': 'terrestre',
'largo_m': 16.15,
'ancho_m': 2.59,
'alto_m': 2.3,
'capacidad_m3': 99.11,
'carga_max_kg': 19958,
'pallets': 24}}]},
'modo_tarifario': {'label': 'Modo de tarifario',
'is_system': True,
'items': [{'code': 'aereo', 'label': 'Aéreo'},
{'code': 'maritimo_fcl', 'label': 'Marítimo FCL'},
{'code': 'maritimo_lcl', 'label': 'Marítimo LCL'},
{'code': 'terrestre', 'label': 'Terrestre'}]},
'unidad_tarifa': {'label': 'Unidad de tarifa',
'is_system': True,
'items': [{'code': 'per_kg', 'label': 'Por kg'},
{'code': 'per_wm', 'label': 'Por peso/medida (W/M)'},
{'code': 'per_container', 'label': 'Por contenedor'},
{'code': 'flat', 'label': 'Tarifa plana'}]},
'concepto_cargo': {'label': 'Concepto de cargo',
'is_system': False,
'items': [{'code': 'combustible', 'label': 'Combustible (BAF/FSC)'},
{'code': 'dgr', 'label': 'Mercancía peligrosa (DGR)'},
{'code': 'moc', 'label': 'MOC (mínimo origen)'},
{'code': 'afs', 'label': 'AFS'},
{'code': 'thc', 'label': 'THC (manejo en terminal)'},
{'code': 'maniobras', 'label': 'Maniobras'},
{'code': 'almacenaje', 'label': 'Almacenaje'},
{'code': 'seguro', 'label': 'Seguro'},
{'code': 'despacho', 'label': 'Despacho aduanal'},
{'code': 'documentacion', 'label': 'Documentación'},
{'code': 'custodia', 'label': 'Custodia'},
{'code': 'otro', 'label': 'Otro'}]}}
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'}]},
})

View File

@@ -0,0 +1,168 @@
"""Lógica de negocio de los catálogos de referencia del CRM."""
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from core.security import is_hub_admin
from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta
from .models import CatalogItem
from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS
# Metadata de catálogos (labels y si el cliente puede llenarlos).
CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()}
CATALOG_LABELS.update(TENANT_CATALOG_LABELS)
# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft).
TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys())
KNOWN_CATALOGS = set(CATALOG_LABELS.keys())
def _require_known(catalog: str) -> None:
if catalog not in KNOWN_CATALOGS:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe")
def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]:
"""Lista todos los catálogos disponibles con su conteo (global + del tenant)."""
out: list[CatalogMeta] = []
for key, label in CATALOG_LABELS.items():
is_tenant = key in TENANT_CATALOG_KEYS
count = (
db.query(CatalogItem)
.filter(
CatalogItem.catalog == key,
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
)
.count()
)
out.append(
CatalogMeta(
catalog=key,
label=label,
scope="tenant" if is_tenant else "global",
is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)),
count=count,
)
)
return out
def list_items(
db: Session,
catalog: str,
tenant_id: int,
parent_code: str | None = None,
include_inactive: bool = False,
) -> list[CatalogItem]:
_require_known(catalog)
q = db.query(CatalogItem).filter(
CatalogItem.catalog == catalog,
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
)
if not include_inactive:
q = q.filter(CatalogItem.is_active.is_(True))
if parent_code:
q = q.filter(CatalogItem.parent_code == parent_code)
return q.order_by(CatalogItem.sort_order, CatalogItem.label).all()
def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None:
"""Devuelve el tenant_id a usar al escribir (None = global) y valida permisos.
- scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft).
- scope 'tenant' (default) → se guarda en el tenant del usuario.
"""
wants_global = scope == "global"
if wants_global:
if not is_hub_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo un administrador de Aduanasoft puede editar catálogos globales.",
)
return None
return int(current_user["tenant_id"])
def create_item(
db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None
) -> CatalogItem:
_require_known(catalog)
target_tenant = _resolve_write_scope(catalog, scope, current_user)
# No duplicar por (catalog, code, tenant_id)
exists = (
db.query(CatalogItem)
.filter(
CatalogItem.catalog == catalog,
CatalogItem.code == data.code,
CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant,
)
.first()
)
if exists:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.",
)
item = CatalogItem(
catalog=catalog,
code=data.code,
label=data.label,
parent_catalog=data.parent_catalog,
parent_code=data.parent_code,
tenant_id=target_tenant,
sort_order=data.sort_order,
is_active=data.is_active,
is_system=False,
created_by=current_user.get("sub"),
updated_by=current_user.get("sub"),
)
db.add(item)
db.commit()
db.refresh(item)
return item
def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem:
_require_known(catalog)
item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first()
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
if item.tenant_id is None:
# Global (Aduanasoft): solo hub_admin.
if not is_hub_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Solo un administrador de Aduanasoft puede editar este catálogo global.",
)
elif item.tenant_id != int(current_user["tenant_id"]):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
return item
def update_item(
db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict
) -> CatalogItem:
item = _get_writable(db, catalog, item_id, current_user)
payload: dict[str, Any] = data.model_dump(exclude_unset=True)
for field, value in payload.items():
setattr(item, field, value)
item.updated_by = current_user.get("sub")
db.commit()
db.refresh(item)
return item
def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None:
item = _get_writable(db, catalog, item_id, current_user)
if item.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.",
)
db.delete(item)
db.commit()

View File

@@ -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)}"

View File

@@ -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))

View File

@@ -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)

View File

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

View File

@@ -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
)

View File

@@ -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)

View File

@@ -16,6 +16,9 @@ _ENTITIES = [
("contact", "contactos"),
("address", "direcciones"),
("document", "documentos"),
("service_request", "solicitudes de servicio"),
("rate_request", "solicitudes de tarifa"),
("quote", "cotizaciones"),
("lead", "prospectos"),
("opportunity", "oportunidades"),
("pipeline", "embudos"),

View File

@@ -0,0 +1,132 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, computed_field
# ----- Quote items -----
class QuoteItemBase(BaseModel):
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
supplier_id: int | None = None
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
unit_cost: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
unit_sale: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
class QuoteItemCreate(QuoteItemBase):
quote_id: int
class QuoteItemUpdate(BaseModel):
concept: str | None = Field(None, max_length=60)
description: str | None = Field(None, max_length=255)
supplier_id: int | None = None
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
unit_cost: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
unit_sale: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
class QuoteItemResponse(QuoteItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
quote_id: int
tenant_id: int
company_id: int
@computed_field
@property
def line_cost(self) -> Decimal:
return (self.quantity or Decimal(0)) * (self.unit_cost or Decimal(0))
@computed_field
@property
def line_sale(self) -> Decimal:
return (self.quantity or Decimal(0)) * (self.unit_sale or Decimal(0))
# ----- Quotes -----
class QuoteBase(BaseModel):
reference: str | None = Field(None, max_length=40)
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
terms: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class QuoteCreate(QuoteBase):
pass
class QuoteUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
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
terms: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class QuoteResponse(QuoteBase):
model_config = ConfigDict(from_attributes=True)
id: int
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
created_by: str | None = None
updated_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
@computed_field
@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

View File

@@ -0,0 +1,87 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Quote(Base, TenantScopedMixin, TimestampMixin):
"""Cotización (Diagrama 1, pasos 7-9). Integra los conceptos de costo/venta."""
__tablename__ = "quotes"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
account_id: Mapped[int | None] = mapped_column(
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)
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
total_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
total_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
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)."""
__tablename__ = "quote_items"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
quote_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=False, index=True
)
# flete_internacional | transporte_terrestre | despacho_aduanal | gastos_destino | otros
concept: Mapped[str] = mapped_column(String(60), nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
)
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
unit_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
unit_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)

View File

@@ -0,0 +1,376 @@
"""Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema.
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
import io
from decimal import Decimal
_W = 612
_H = 792
_ML = 50 # margen izquierdo
_MR = 562 # margen derecho (x)
CONCEPT_LABELS = {
"flete_internacional": "Flete internacional",
"transporte_terrestre": "Transporte terrestre",
"despacho_aduanal": "Despacho aduanal",
"gastos_destino": "Gastos en destino",
"otros": "Otros cargos",
}
_TRANSLATE = str.maketrans({"": "-", "": "-", "": '"', "": '"', "": "'", "": "'", "": "-", "": "...", "\t": " "})
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 _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 []
out, cur = [], ""
for w in words:
cand = f"{cur} {w}".strip()
if len(cand) > width_chars and cur:
out.append(cur)
cur = w
else:
cur = cand
if cur:
out.append(cur)
return out
def _hex_rgb(hexs: str | None) -> tuple[float, float, float]:
try:
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.07, 0.16, 0.30)
def _prep_logo(logo_bytes: bytes | 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=88)
return buf.getvalue(), im.width, im.height
except Exception:
return None
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(
*,
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 = "#12294c",
) -> bytes:
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)
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):
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
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")
if not kv_block([
("Cliente", client.get("name")), ("RFC", client.get("rfc")),
("Correo", client.get("email")), ("Teléfono", client.get("phone")),
]):
c.text(_ML + 6, c.y - 11, "", 9, GRAY)
c.y -= 16
# ---------------- Carga / Ruta (solo si hay datos) ----------------
if [v for _, v in cargo if v not in (None, "", "None")]:
section("Información de la carga")
kv_block(cargo)
if [v for _, v in route if v not in (None, "", "None")]:
section("Ruta logística")
kv_block(route)
# ---------------- Costos ----------------
section("Costos cotizados")
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}"
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_sale") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
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
# ---------------- 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 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
# pie en todas las páginas
for ops in c.pages:
if footer:
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 ----------------
streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages]
objects: list[bytes] = []
def add(obj: bytes):
objects.append(obj)
n_pages = len(c.pages)
has_img = 1 if logo else 0
# 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))
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 >>")
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
if logo:
jpeg, lw, lh = logo
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 = "/Font << /F1 3 0 R /F2 4 0 R >>"
if has_img and i == 0:
res += f" /XObject << /Im0 {img_num} 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")
)
for stream in streams:
add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
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") + 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)

View File

@@ -0,0 +1,250 @@
"""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 _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(
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 "#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
# 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 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),
]
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 = (
"<div style='font-family:Arial,sans-serif;color:#333;max-width:600px'>"
f"<p>{(message or 'Adjunto la cotización solicitada. Quedamos atentos.').replace(chr(10), '<br>')}</p>"
f"<p style='color:#6b7280;font-size:12px'>Cotización {ref}</p></div>"
)
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)
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:
# 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}")
# 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}

View File

@@ -0,0 +1,267 @@
from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
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")
@router.get("/quotes", response_model=list[QuoteResponse])
def list_quotes(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
quote_status: str | None = Query(None, alias="status"),
account_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_quotes(db, tenant_id, company_id, search, quote_status, account_id)
@router.get("/quotes/{quote_id}", response_model=QuoteResponse)
def get_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_quote(db, quote_id, tenant_id, company_id)
@router.post("/quotes", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
def create_quote(
payload: QuoteCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
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,
payload: QuoteUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.update_quote(db, quote_id, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/quotes/{quote_id}/send", response_model=QuoteResponse)
def send_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.send_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.patch("/quotes/{quote_id}/accept", response_model=QuoteResponse)
def accept_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.accept_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.patch("/quotes/{quote_id}/reject", response_model=QuoteResponse)
def reject_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
@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),
):
"""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")
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,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Clona la cotización como borrador para re-cotizar (R-C-12)."""
return service.clone_quote(db, quote_id, current_user["tenant_id"], company_id, _user_id(current_user))
@router.delete("/quotes/{quote_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_quote(db, quote_id, current_user["tenant_id"], company_id)
# ----- Conceptos de la cotización -----
@router.get("/quotes/{quote_id}/items", response_model=list[QuoteItemResponse])
def list_quote_items(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_quote_items(db, quote_id, current_user["tenant_id"], company_id)
@router.post("/quote-items", response_model=QuoteItemResponse, status_code=status.HTTP_201_CREATED)
def create_quote_item(
payload: QuoteItemCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_quote_item(db, payload, current_user["tenant_id"], company_id)
@router.patch("/quote-items/{item_id}", response_model=QuoteItemResponse)
def update_quote_item(
item_id: int,
payload: QuoteItemUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_quote_item(db, item_id, payload, current_user["tenant_id"], company_id)
@router.delete("/quote-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_quote_item(
item_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_quote_item(db, item_id, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,400 @@
from datetime import date, datetime, timezone
from decimal import Decimal
from fastapi import HTTPException, status
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 ..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
from .models import Quote, QuoteItem
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
if _id is None:
return True
return (
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
is not None
)
def _validate_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, ServiceRequest, data.get("service_request_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La solicitud asociada no existe")
def _recompute_totals(db: Session, quote: Quote) -> None:
"""Recalcula total_cost/total_sale a partir de los conceptos vigentes."""
cost, sale = (
db.query(
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_cost), 0),
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_sale), 0),
)
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
.one()
)
quote.total_cost = Decimal(cost or 0)
quote.total_sale = Decimal(sale or 0)
# ----- Quotes -----
def get_quotes(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
quote_status: str | None = None,
account_id: int | None = None,
) -> list[Quote]:
query = db.query(Quote).filter(
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
if quote_status:
query = query.filter(Quote.status == quote_status)
if account_id is not None:
query = query.filter(Quote.account_id == account_id)
if search:
query = query.filter(Quote.reference.ilike(f"%{search}%"))
return query.order_by(Quote.created_at.desc()).all()
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
obj = (
db.query(Quote)
.filter(
Quote.id == quote_id,
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
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()
)
# 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:
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,
))
# 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,
))
# 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)
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:
obj = get_quote(db, quote_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> None:
obj = get_quote(db, quote_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def _set_service_request_status(db: Session, quote: Quote, new_status: str) -> None:
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
if sr:
sr.status = new_status
def send_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "enviada"
quote.sent_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "cotizada")
db.commit()
db.refresh(quote)
return quote
def accept_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "aceptada"
quote.accepted_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "aceptada")
db.commit()
db.refresh(quote)
return quote
def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "rechazada"
quote.rejected_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "rechazada")
db.commit()
db.refresh(quote)
return quote
def clone_quote(
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
"""Clona una cotización (y sus conceptos) como borrador para re-cotizar (R-C-12).
Si la cotización origen fue rechazada, reabre su solicitud a 'en_analisis' para
cerrar el ciclo de reintento del Diagrama 1.
"""
src = get_quote(db, quote_id, tenant_id, company_id)
new_quote = Quote(
reference=(f"{src.reference}-R" if src.reference else None),
service_request_id=src.service_request_id,
account_id=src.account_id,
currency=src.currency,
status="borrador",
valid_until=src.valid_until,
notes=src.notes,
terms=src.terms,
owner_user_id=src.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(new_quote)
db.flush()
src_items = (
db.query(QuoteItem)
.filter(QuoteItem.quote_id == src.id, QuoteItem.deleted_at.is_(None))
.all()
)
for it in src_items:
db.add(QuoteItem(
quote_id=new_quote.id, concept=it.concept, description=it.description,
supplier_id=it.supplier_id, quantity=it.quantity, unit_cost=it.unit_cost,
unit_sale=it.unit_sale, currency=it.currency,
tenant_id=tenant_id, company_id=company_id,
))
db.flush()
_recompute_totals(db, new_quote)
# Reabre la solicitud origen para el ciclo de re-cotización
if src.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == src.service_request_id).first()
if sr and sr.status in ("rechazada", "cotizada"):
sr.status = "en_analisis"
db.commit()
db.refresh(new_quote)
return new_quote
# ----- Quote items -----
def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]:
get_quote(db, quote_id, tenant_id, company_id) # valida scope
return (
db.query(QuoteItem)
.filter(
QuoteItem.quote_id == quote_id,
QuoteItem.tenant_id == tenant_id,
QuoteItem.company_id == company_id,
QuoteItem.deleted_at.is_(None),
)
.order_by(QuoteItem.id.asc())
.all()
)
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> QuoteItem:
item = (
db.query(QuoteItem)
.filter(
QuoteItem.id == item_id,
QuoteItem.tenant_id == tenant_id,
QuoteItem.company_id == company_id,
QuoteItem.deleted_at.is_(None),
)
.first()
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
return item
def create_quote_item(db: Session, payload: QuoteItemCreate, tenant_id: int, company_id: int) -> QuoteItem:
quote = get_quote(db, payload.quote_id, tenant_id, company_id)
if not _exists(db, Supplier, payload.supplier_id, tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
item = QuoteItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(item)
db.flush()
_recompute_totals(db, quote)
db.commit()
db.refresh(item)
return item
def update_quote_item(
db: Session, item_id: int, payload: QuoteItemUpdate, tenant_id: int, company_id: int
) -> QuoteItem:
item = _get_item(db, item_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
for field, value in data.items():
setattr(item, field, value)
db.flush()
quote = get_quote(db, item.quote_id, tenant_id, company_id)
_recompute_totals(db, quote)
db.commit()
db.refresh(item)
return item
def delete_quote_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None:
item = _get_item(db, item_id, tenant_id, company_id)
quote_id = item.quote_id
item.deleted_at = datetime.now(timezone.utc)
db.flush()
quote = get_quote(db, quote_id, tenant_id, company_id)
_recompute_totals(db, quote)
db.commit()

View File

@@ -0,0 +1,178 @@
"""Schemas del módulo Tarifario."""
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
# ---------- Quiebres y cargos ----------
class RateBreakDTO(BaseModel):
model_config = ConfigDict(from_attributes=True)
from_qty: Decimal = Field(0)
rate: Decimal = Field(0)
class RateChargeDTO(BaseModel):
model_config = ConfigDict(from_attributes=True)
concept: str = Field(..., max_length=60)
charge_type: str = Field("fijo", max_length=20)
value: Decimal | None = None
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)
destination: str | None = Field(None, max_length=20)
region: str | None = Field(None, max_length=60)
equipment_type: str | None = Field(None, max_length=20)
rate_unit: str | None = Field(None, max_length=20)
min_charge: Decimal | None = None
flat_rate: Decimal | None = None
transit_days: int | None = None
notes: str | None = None
class RateLaneCreate(RateLaneBase):
breaks: list[RateBreakDTO] = Field(default_factory=list)
class RateLaneUpdate(RateLaneBase):
breaks: list[RateBreakDTO] | None = None
class RateLaneResponse(RateLaneBase):
model_config = ConfigDict(from_attributes=True)
id: int
rate_sheet_id: int
breaks: list[RateBreakDTO] = Field(default_factory=list)
# ---------- Tarifario (cabecera) ----------
class RateSheetBase(BaseModel):
supplier_id: int | None = None
mode: str = Field(..., max_length=20)
name: str = Field(..., min_length=1, max_length=255)
currency: str | None = Field("USD", max_length=3)
valid_from: date | None = None
valid_to: date | None = None
default_origin: str | None = Field(None, max_length=20)
status: str = Field("borrador", max_length=20)
notes: str | None = None
class RateSheetCreate(RateSheetBase):
pass
class RateSheetUpdate(BaseModel):
supplier_id: int | None = None
mode: str | None = Field(None, max_length=20)
name: str | None = Field(None, max_length=255)
currency: str | None = Field(None, max_length=3)
valid_from: date | None = None
valid_to: date | None = None
default_origin: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
notes: str | None = None
class RateSheetResponse(RateSheetBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
source_file: str | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
lane_count: int | None = None
# ---------- Importación ----------
class ImportPreviewRow(BaseModel):
row: int
data: dict
ok: bool
warnings: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
class ImportPreview(BaseModel):
mode: str
total: int
valid: int
rows: list[ImportPreviewRow]
columns: list[str]
class ImportConfirm(RateSheetCreate):
lanes: list[RateLaneCreate]
# ---------- Costeo ----------
class CostRequest(BaseModel):
mode: str
origin: str | None = None
destination: str | None = None
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
class CostChargeLine(BaseModel):
concept: str
amount: Decimal
class CostOption(BaseModel):
rate_sheet_id: int
rate_sheet_name: str
supplier_id: int | None
currency: str | None
chargeable: Decimal | None = None # peso/wm facturable usado
base_cost: Decimal
charges: list[CostChargeLine] = Field(default_factory=list)
total_cost: Decimal
transit_days: int | None = None
detail: str | None = None
class CostResult(BaseModel):
request: CostRequest
options: list[CostOption]

View File

@@ -0,0 +1,93 @@
"""Modelos del módulo Tarifario (base de costos para Cotizaciones).
Un ``RateSheet`` (tarifario) pertenece a un proveedor y agrupa muchas
``RateLane`` (rutas origen→destino). Cada ruta tiene, según el modo:
- Aéreo / LCL: varios ``RateBreak`` (quiebres de peso/volumen con su tarifa).
- FCL / terrestre: una tarifa plana por contenedor/unidad (``flat_rate``).
Los ``RateCharge`` son cargos adicionales a nivel tarifario o ruta.
"""
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class RateSheet(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_sheets"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
)
# aereo | maritimo_fcl | maritimo_lcl | terrestre
mode: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'USD'"))
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True)
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True)
default_origin: Mapped[str | None] = mapped_column(String(20), nullable=True)
# borrador | activo | vencido | reemplazado
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"))
source_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, 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 RateLane(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_lanes"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
rate_sheet_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.rate_sheets.id"), nullable=False, index=True
)
origin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
destination: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
region: Mapped[str | None] = mapped_column(String(60), nullable=True)
# Solo FCL/terrestre (código del catálogo tipo_equipo). Nulo en aéreo/LCL.
equipment_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
# per_kg | per_wm | per_container | flat
rate_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
min_charge: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
flat_rate: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
transit_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class RateBreak(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_breaks"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
rate_lane_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.rate_lanes.id"), nullable=False, index=True
)
# Umbral del quiebre (kg en aéreo; W/M en LCL)
from_qty: Mapped[Decimal] = mapped_column(Numeric(12, 3), nullable=False, server_default=text("0"))
rate: Mapped[Decimal] = mapped_column(Numeric(14, 4), nullable=False, server_default=text("0"))
class RateCharge(Base, TenantScopedMixin, TimestampMixin):
__tablename__ = "rate_charges"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
rate_sheet_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.rate_sheets.id"), nullable=True, index=True
)
rate_lane_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.rate_lanes.id"), nullable=True, index=True
)
concept: Mapped[str] = mapped_column(String(60), nullable=False)
# fijo | por_kg | por_guia | por_contenedor | porcentaje
charge_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'fijo'"))
value: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
condition: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,257 @@
"""Endpoints del módulo Tarifario."""
from datetime import date
from fastapi import APIRouter, Depends, File, Form, Query, Response, 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 .dto import (
CostRequest,
CostResult,
ImportPreview,
RateBreakDTO,
RateChargeCreate,
RateChargeResponse,
RateChargeUpdate,
RateLaneCreate,
RateLaneResponse,
RateSheetCreate,
RateSheetResponse,
RateSheetUpdate,
)
router = APIRouter(prefix="/rate-sheets", tags=["Tarifario"])
def _ctx(current_user: dict):
return current_user["tenant_id"], current_user.get("sub") or current_user.get("id")
def _sheet_out(db: Session, tenant_id: int, sheet) -> RateSheetResponse:
out = RateSheetResponse.model_validate(sheet)
out.lane_count = service.lane_count(db, tenant_id, sheet.id)
return out
def _lane_out(db: Session, lane) -> RateLaneResponse:
out = RateLaneResponse.model_validate(lane)
out.breaks = [RateBreakDTO.model_validate(b) for b in service.breaks_of(db, lane.id)]
return out
# ---------------- Tarifarios ----------------
@router.get("", response_model=list[RateSheetResponse])
def list_sheets(
company_id: int = Query(...),
mode: str | None = Query(None),
supplier_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
sheets = service.list_sheets(db, tenant_id, company_id, mode=mode, supplier_id=supplier_id)
return [_sheet_out(db, tenant_id, s) for s in sheets]
@router.post("", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
def create_sheet(
data: RateSheetCreate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, user_id = _ctx(current_user)
sheet = service.create_sheet(db, tenant_id, company_id, data, user_id)
return _sheet_out(db, tenant_id, sheet)
@router.get("/template")
def download_template(
mode: str = Query(..., description="aereo | maritimo_fcl | maritimo_lcl | terrestre"),
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
):
content = service.build_template(mode)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="plantilla_tarifario_{mode}.xlsx"'},
)
@router.post("/import/preview", response_model=ImportPreview)
async def import_preview(
company_id: int = Query(...),
mode: str = Form(...),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
):
content = await file.read()
return service.parse_excel(mode, content)
@router.post("/import", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
async def import_sheet(
company_id: int = Query(...),
mode: str = Form(...),
name: str = Form(...),
supplier_id: int | None = Form(None),
currency: str = Form("USD"),
valid_from: date | None = Form(None),
valid_to: date | None = Form(None),
default_origin: str | None = Form(None),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, user_id = _ctx(current_user)
content = await file.read()
header = RateSheetCreate(
mode=mode, name=name, supplier_id=supplier_id, currency=currency,
valid_from=valid_from, valid_to=valid_to, default_origin=default_origin,
)
sheet = service.import_from_excel(db, tenant_id, company_id, mode, content, header, user_id)
return _sheet_out(db, tenant_id, sheet)
@router.get("/{sheet_id}", response_model=RateSheetResponse)
def get_sheet(
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)
return _sheet_out(db, tenant_id, service.get_sheet(db, tenant_id, company_id, sheet_id))
@router.patch("/{sheet_id}", response_model=RateSheetResponse)
def update_sheet(
sheet_id: int,
data: RateSheetUpdate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, user_id = _ctx(current_user)
return _sheet_out(db, tenant_id, service.update_sheet(db, tenant_id, company_id, sheet_id, data, user_id))
@router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_sheet(
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.delete_sheet(db, tenant_id, company_id, sheet_id)
# ---------------- Rutas (lanes) ----------------
@router.get("/{sheet_id}/lanes", response_model=list[RateLaneResponse])
def list_lanes(
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 [_lane_out(db, lane) for lane in service.list_lanes(db, tenant_id, sheet_id)]
@router.post("/{sheet_id}/lanes", response_model=RateLaneResponse, status_code=status.HTTP_201_CREATED)
def create_lane(
sheet_id: int,
data: RateLaneCreate,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id, _ = _ctx(current_user)
lane = service.create_lane(db, tenant_id, company_id, sheet_id, data)
return _lane_out(db, lane)
@router.delete("/{sheet_id}/lanes/{lane_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_lane(
sheet_id: int,
lane_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_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"])
@cost_router.post("/rate-quote", response_model=CostResult)
def rate_quote(
req: CostRequest,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Calcula opciones de costo (por proveedor) para una ruta/carga."""
tenant_id, _ = _ctx(current_user)
options = service.quote_cost(db, tenant_id, company_id, req)
return CostResult(request=req, options=options)

View File

@@ -0,0 +1,541 @@
"""Lógica del módulo Tarifario: CRUD, importación por Excel y motor de costeo."""
import io
from datetime import date
from decimal import Decimal
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from .dto import (
CostChargeLine,
CostOption,
CostRequest,
ImportConfirm,
ImportPreview,
ImportPreviewRow,
RateLaneCreate,
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")
# ============================================================ CRUD tarifarios
def _sheet_query(db: Session, tenant_id: int, company_id: int):
return db.query(RateSheet).filter(
RateSheet.tenant_id == tenant_id,
RateSheet.company_id == company_id,
RateSheet.deleted_at.is_(None),
)
def list_sheets(db: Session, tenant_id: int, company_id: int, mode: str | None = None,
supplier_id: int | None = None) -> list[RateSheet]:
q = _sheet_query(db, tenant_id, company_id)
if mode:
q = q.filter(RateSheet.mode == mode)
if supplier_id:
q = q.filter(RateSheet.supplier_id == supplier_id)
return q.order_by(RateSheet.created_at.desc()).all()
def lane_count(db: Session, tenant_id: int, sheet_id: int) -> int:
return (
db.query(RateLane)
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
RateLane.deleted_at.is_(None))
.count()
)
def get_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> RateSheet:
sheet = _sheet_query(db, tenant_id, company_id).filter(RateSheet.id == sheet_id).first()
if not sheet:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tarifario no encontrado")
return sheet
def create_sheet(db: Session, tenant_id: int, company_id: int, data: RateSheetCreate,
user_id: str | None) -> RateSheet:
sheet = RateSheet(
tenant_id=tenant_id, company_id=company_id,
**data.model_dump(),
created_by=user_id, updated_by=user_id,
)
db.add(sheet)
db.commit()
db.refresh(sheet)
return sheet
def update_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int,
data: RateSheetUpdate, user_id: str | None) -> RateSheet:
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
for field, value in data.model_dump(exclude_unset=True).items():
setattr(sheet, field, value)
sheet.updated_by = user_id
db.commit()
db.refresh(sheet)
return sheet
def delete_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> None:
from sqlalchemy import func
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
sheet.deleted_at = func.now()
db.commit()
# ============================================================ Rutas (lanes)
def list_lanes(db: Session, tenant_id: int, sheet_id: int) -> list[RateLane]:
return (
db.query(RateLane)
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
RateLane.deleted_at.is_(None))
.order_by(RateLane.region, RateLane.destination)
.all()
)
def breaks_of(db: Session, lane_id: int) -> list[RateBreak]:
return (
db.query(RateBreak)
.filter(RateBreak.rate_lane_id == lane_id, RateBreak.deleted_at.is_(None))
.order_by(RateBreak.from_qty)
.all()
)
def _add_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
lane_data: RateLaneCreate) -> RateLane:
payload = lane_data.model_dump(exclude={"breaks"})
lane = RateLane(tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, **payload)
db.add(lane)
db.flush() # id
for br in lane_data.breaks:
db.add(RateBreak(
tenant_id=tenant_id, company_id=company_id, rate_lane_id=lane.id,
from_qty=br.from_qty, rate=br.rate,
))
return lane
def create_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
lane_data: RateLaneCreate) -> RateLane:
get_sheet(db, tenant_id, company_id, sheet_id) # valida pertenencia
lane = _add_lane(db, tenant_id, company_id, sheet_id, lane_data)
db.commit()
db.refresh(lane)
return lane
def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> None:
from sqlalchemy import func
lane = (
db.query(RateLane)
.filter(RateLane.id == lane_id, RateLane.rate_sheet_id == sheet_id,
RateLane.tenant_id == tenant_id)
.first()
)
if not lane:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ruta no encontrada")
lane.deleted_at = func.now()
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]] = {
"aereo": ["Region", "Origen", "Destino", "IATA", "Min", "100", "300", "500", "1000"],
"maritimo_fcl": ["Origen", "Destino", "Tipo contenedor", "Tarifa", "Transito", "Notas"],
"maritimo_lcl": ["Origen", "Destino", "Tarifa W/M", "Minimo", "Notas"],
"terrestre": ["Origen", "Destino", "Tarifa", "Transito", "Notas"],
}
def build_template(mode: str) -> bytes:
"""Genera un .xlsx con los encabezados del modo + una fila de ejemplo."""
import openpyxl
if mode not in TEMPLATES:
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = mode
headers = TEMPLATES[mode]
ws.append(headers)
examples = {
"aereo": ["EUROPA", "NLU", "Frankfurt", "FRA", 190, 1.00, 1.00, 0.95, 0.90],
"maritimo_fcl": ["MXZLO", "CNSHA", "40HC", 2500, 28, "THC no incluido"],
"maritimo_lcl": ["MXZLO", "USLAX", 45, 80, "1 W/M = 1 ton o 1 m3"],
"terrestre": ["Monterrey", "Laredo", 850, 1, ""],
}
ws.append(examples[mode])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _num(v: Any) -> Decimal | None:
if v is None or v == "":
return None
try:
return Decimal(str(v).replace("$", "").replace(",", "").strip())
except Exception:
return None
def parse_excel(mode: str, content: bytes) -> ImportPreview:
"""Lee el Excel y devuelve una vista previa con validaciones (no persiste)."""
import openpyxl
if mode not in TEMPLATES:
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
try:
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True, read_only=True)
except Exception:
raise HTTPException(status_code=400, detail="No se pudo leer el archivo Excel")
ws = wb.active
rows_iter = ws.iter_rows(values_only=True)
header = next(rows_iter, None)
if not header:
raise HTTPException(status_code=400, detail="El archivo está vacío")
cols = [str(c).strip() if c is not None else "" for c in header]
idx = {name.lower(): i for i, name in enumerate(cols)}
def cell(row, name):
i = idx.get(name.lower())
return row[i] if i is not None and i < len(row) else None
preview_rows: list[ImportPreviewRow] = []
valid = 0
for n, row in enumerate(rows_iter, start=2):
if row is None or all(c is None or str(c).strip() == "" for c in row):
continue
errors: list[str] = []
warnings: list[str] = []
data: dict = {}
if mode == "aereo":
data = {
"region": cell(row, "Region"),
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino") or cell(row, "IATA"),
"iata": cell(row, "IATA"),
"min_charge": _num(cell(row, "Min")),
"breaks": {b: _num(cell(row, b)) for b in ("100", "300", "500", "1000")},
}
if not data["destination"]:
errors.append("Falta destino/IATA")
if not any(v is not None for v in data["breaks"].values()):
errors.append("Sin tarifas por quiebre")
elif mode == "maritimo_fcl":
data = {
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino"),
"equipment_type": cell(row, "Tipo contenedor"),
"flat_rate": _num(cell(row, "Tarifa")),
"transit_days": _num(cell(row, "Transito")),
"notes": cell(row, "Notas"),
}
if data["flat_rate"] is None:
errors.append("Falta la tarifa")
if not data["equipment_type"]:
warnings.append("Sin tipo de contenedor")
elif mode == "maritimo_lcl":
data = {
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino"),
"wm_rate": _num(cell(row, "Tarifa W/M")),
"min_charge": _num(cell(row, "Minimo")),
"notes": cell(row, "Notas"),
}
if data["wm_rate"] is None:
errors.append("Falta la tarifa W/M")
else: # terrestre
data = {
"origin": cell(row, "Origen"),
"destination": cell(row, "Destino"),
"flat_rate": _num(cell(row, "Tarifa")),
"transit_days": _num(cell(row, "Transito")),
"notes": cell(row, "Notas"),
}
if data["flat_rate"] is None:
errors.append("Falta la tarifa")
if not data.get("destination"):
errors.append("Falta destino")
ok = not errors
if ok:
valid += 1
preview_rows.append(ImportPreviewRow(row=n, data=_jsonable(data), ok=ok,
warnings=warnings, errors=errors))
return ImportPreview(mode=mode, total=len(preview_rows), valid=valid,
rows=preview_rows, columns=cols)
def _jsonable(d: dict) -> dict:
out = {}
for k, v in d.items():
if isinstance(v, Decimal):
out[k] = float(v)
elif isinstance(v, dict):
out[k] = {kk: (float(vv) if isinstance(vv, Decimal) else vv) for kk, vv in v.items()}
else:
out[k] = v
return out
def _rows_to_lanes(mode: str, rows: list[ImportPreviewRow], default_origin: str | None) -> list[RateLaneCreate]:
lanes: list[RateLaneCreate] = []
for r in rows:
if not r.ok:
continue
d = r.data
origin = d.get("origin") or default_origin
if mode == "aereo":
breaks = [
{"from_qty": Decimal(b), "rate": Decimal(str(v))}
for b, v in (d.get("breaks") or {}).items() if v is not None
]
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None,
destination=str(d.get("destination")),
region=d.get("region"), rate_unit="per_kg",
min_charge=_num(d.get("min_charge")),
breaks=breaks, # type: ignore[arg-type]
))
elif mode == "maritimo_fcl":
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None, destination=str(d.get("destination")),
equipment_type=d.get("equipment_type"), rate_unit="per_container",
flat_rate=_num(d.get("flat_rate")),
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
notes=d.get("notes"),
))
elif mode == "maritimo_lcl":
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None, destination=str(d.get("destination")),
rate_unit="per_wm", min_charge=_num(d.get("min_charge")),
breaks=[{"from_qty": Decimal(0), "rate": Decimal(str(d["wm_rate"]))}], # type: ignore[arg-type]
notes=d.get("notes"),
))
else:
lanes.append(RateLaneCreate(
origin=str(origin) if origin else None, destination=str(d.get("destination")),
rate_unit="flat", flat_rate=_num(d.get("flat_rate")),
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
notes=d.get("notes"),
))
return lanes
def confirm_import(db: Session, tenant_id: int, company_id: int, data: ImportConfirm,
user_id: str | None) -> RateSheet:
"""Crea el tarifario + rutas a partir de la vista previa confirmada."""
sheet = RateSheet(
tenant_id=tenant_id, company_id=company_id,
supplier_id=data.supplier_id, mode=data.mode, name=data.name,
currency=data.currency, valid_from=data.valid_from, valid_to=data.valid_to,
default_origin=data.default_origin, status=data.status or "borrador",
notes=data.notes, created_by=user_id, updated_by=user_id,
)
db.add(sheet)
db.flush()
for lane in data.lanes:
_add_lane(db, tenant_id, company_id, sheet.id, lane)
db.commit()
db.refresh(sheet)
return sheet
def import_from_excel(db: Session, tenant_id: int, company_id: int, mode: str,
content: bytes, header: RateSheetCreate, user_id: str | None) -> RateSheet:
"""Atajo: parsea el Excel y crea el tarifario en un solo paso."""
preview = parse_excel(mode, content)
lanes = _rows_to_lanes(mode, preview.rows, header.default_origin)
return confirm_import(
db, tenant_id, company_id,
ImportConfirm(**header.model_dump(), lanes=lanes), user_id,
)
# ============================================================ Motor de costeo
def _volumetric_kg(volume_m3: Decimal | None) -> Decimal:
return (volume_m3 or Decimal(0)) * AIR_VOLUMETRIC_FACTOR
def _rate_for(breaks: list[RateBreak], qty: Decimal) -> Decimal | None:
"""Tarifa aplicable al peso/wm 'qty' (mayor quiebre cuyo umbral <= qty)."""
if not breaks:
return None
applicable = None
for b in breaks:
if b.from_qty <= qty:
applicable = b.rate
if applicable is None:
applicable = breaks[0].rate # por debajo del primer quiebre → tarifa base (gobierna el mínimo)
return applicable
def _best_break_cost(breaks: list[RateBreak], qty: Decimal) -> Decimal:
"""Costo base con optimización de quiebre (declarar peso mayor si conviene)."""
base_rate = _rate_for(breaks, qty)
base = (qty * base_rate) if base_rate is not None else Decimal(0)
for b in breaks:
if b.from_qty > qty:
candidate = b.from_qty * b.rate
if candidate < base:
base = candidate
return base
def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
chargeable: Decimal, quantity: int, dangerous: bool) -> list[CostChargeLine]:
charges = (
db.query(RateCharge)
.filter(
RateCharge.deleted_at.is_(None),
or_(RateCharge.rate_sheet_id == sheet.id, RateCharge.rate_lane_id == lane.id),
)
.all()
)
lines: list[CostChargeLine] = []
for c in charges:
if c.concept == "dgr" and not dangerous:
continue
v = c.value or Decimal(0)
if c.charge_type == "fijo" or c.charge_type == "por_guia":
amt = v
elif c.charge_type == "por_kg":
amt = v * chargeable
elif c.charge_type == "por_contenedor":
amt = v * quantity
elif c.charge_type == "porcentaje":
amt = base * v / Decimal(100)
else:
amt = v
lines.append(CostChargeLine(concept=c.concept, amount=amt))
return lines
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(
RateSheet.mode == req.mode,
RateSheet.status == "activo",
or_(RateSheet.valid_from.is_(None), RateSheet.valid_from <= on_date),
or_(RateSheet.valid_to.is_(None), RateSheet.valid_to >= on_date),
).all()
gross = req.gross_weight_kg or Decimal(0)
options: list[CostOption] = []
for sheet in sheets:
lanes_q = db.query(RateLane).filter(
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
)
if req.destination:
lanes_q = lanes_q.filter(RateLane.destination == req.destination)
for lane in lanes_q.all():
# Origen: match exacto o el default del tarifario.
lane_origin = lane.origin or sheet.default_origin
if req.origin and lane_origin and lane_origin != req.origin:
continue
if req.mode == "maritimo_fcl":
if req.equipment_type and lane.equipment_type and lane.equipment_type != req.equipment_type:
continue
chargeable = Decimal(req.quantity)
base = (lane.flat_rate or Decimal(0)) * req.quantity
detail = f"{req.quantity} x {lane.equipment_type or 'contenedor'}"
elif req.mode == "terrestre":
chargeable = Decimal(req.quantity)
base = (lane.flat_rate or Decimal(0)) * req.quantity
detail = "tarifa por ruta"
elif req.mode == "maritimo_lcl":
tons = gross / Decimal(1000)
wm = max(tons, req.volume_m3 or Decimal(0))
brks = breaks_of(db, lane.id)
base = _best_break_cost(brks, wm) if brks else Decimal(0)
chargeable = wm
base = max(base, lane.min_charge or Decimal(0))
detail = f"W/M {wm.quantize(Decimal('0.01'))}"
else: # aereo
# 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 (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))
options.append(CostOption(
rate_sheet_id=sheet.id, rate_sheet_name=sheet.name, supplier_id=sheet.supplier_id,
currency=sheet.currency, chargeable=chargeable, base_cost=base,
charges=charge_lines, total_cost=total, transit_days=lane.transit_days, detail=detail,
))
options.sort(key=lambda o: o.total_cost)
return options

View File

@@ -5,29 +5,46 @@ Importar este módulo también registra los permisos del CRM (side-effect de
``permissions``), siguiendo el patrón del ``PermissionRegistry``.
"""
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from . import permissions # noqa: F401 (side-effect: registra permisos del CRM)
from .accounts.routes import router as accounts_router
from .activities.routes import router as activities_router
from .addresses.routes import router as addresses_router
from .catalogs.routes import router as catalogs_router
from .contacts.routes import router as contacts_router
from .documents.routes import router as documents_router
from .leads.routes import router as leads_router
from .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router
from .pipelines.routes import router as pipelines_router
from .quotes.routes import router as quotes_router
from .rates.routes import cost_router as rates_cost_router
from .rates.routes import router as rates_router
from .service_requests.routes import router as service_requests_router
from .suppliers.routes import router as suppliers_router
from .uploads.routes import router as uploads_router
router = APIRouter()
# Enforcement por área/carril (R-T-07): se exige el permiso crm.access para tocar
# cualquier endpoint del módulo. En desarrollo el usuario se auto-bootstrapea a
# super_admin (ver PermissionChecker) para no bloquear el entorno.
router = APIRouter(dependencies=[Depends(PermissionChecker(["crm.access"]))])
router.include_router(accounts_router)
router.include_router(suppliers_router)
router.include_router(contacts_router)
router.include_router(addresses_router)
router.include_router(documents_router)
router.include_router(service_requests_router)
router.include_router(quotes_router)
router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)
router.include_router(activities_router)
router.include_router(metrics_router)
router.include_router(catalogs_router)
router.include_router(uploads_router)
router.include_router(rates_router)
router.include_router(rates_cost_router)

View File

@@ -0,0 +1,209 @@
from datetime import date, datetime
from decimal import Decimal
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) # peso bruto
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
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
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
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)
class ServiceRequestCreate(ServiceRequestBase):
pass
class ServiceRequestContactInput(BaseModel):
"""Registro del contacto al cliente como etapa del flujo comercial (R-C-04)."""
notes: str | None = None
class ServiceRequestFromOpportunityInput(BaseModel):
"""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)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
notes: str | None = None
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)
service_type: str | None = Field(None, max_length=20)
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
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
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)
class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
first_contact_at: datetime | None = None
first_contact_notes: str | None = None
tenant_id: int
company_id: int
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
class RateRequestBase(BaseModel):
service_request_id: int
supplier_id: int | None = None
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
status: str = Field("solicitada", max_length=20)
rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
valid_until: date | None = None
notes: str | None = None
class RateRequestCreate(RateRequestBase):
pass
class RateRequestUpdate(BaseModel):
supplier_id: int | None = None
concept: str | None = Field(None, max_length=60)
description: str | None = Field(None, max_length=255)
status: str | None = Field(None, max_length=20)
rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
valid_until: date | None = None
notes: str | None = None
class RateRequestResponse(RateRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,133 @@
from datetime import date, datetime
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
from core.database import Base
class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
"""Solicitud de cotización / levantamiento de requerimientos (Diagrama 1, pasos 3-5).
Captura los requerimientos logísticos de la operación que el cliente solicita
cotizar (tipo de operación, medio de transporte, ruta, carga, Incoterm, etc.).
"""
__tablename__ = "service_requests"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
# Oportunidad de origen: enlaza el embudo (primer contacto) con la cadena RFQ→cotización (R-C-02)
opportunity_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.opportunities.id"), nullable=True, index=True
)
# importacion | exportacion
operation_type: Mapped[str] = mapped_column(String(20), nullable=False)
# maritimo | aereo | terrestre | ferroviario | multimodal
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
# puerto_puerto | puerto_puerta | puerta_puerto | puerta_puerta
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True)
origin: Mapped[str | None] = mapped_column(String(160), nullable=True)
destination: Mapped[str | None] = mapped_column(String(160), nullable=True)
cargo_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
volume: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
load_type: Mapped[str | None] = mapped_column(String(10), nullable=True) # FCL | LCL
container_equipment: Mapped[str | None] = mapped_column(String(120), nullable=True)
commodity: Mapped[str | None] = mapped_column(Text, nullable=True) # mercancía
required_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Agente en destino / contraparte (proveedor)
destination_agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
)
requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # otros requerimientos
# Contacto al cliente como etapa del flujo comercial (Diagrama 1, paso 2 — R-C-04)
first_contact_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
first_contact_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# nueva | contacto | en_analisis | cotizada | aceptada | rechazada | liberada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'nueva'"), index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
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)
# 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)
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)."""
__tablename__ = "rate_requests"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
service_request_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=False, index=True
)
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
)
# flete_internacional | transporte_terrestre | despacho_aduanal | gastos_destino | otros
concept: Mapped[str] = mapped_column(String(60), nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
# solicitada | recibida | declinada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'solicitada'"))
rate_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,169 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import (
RateRequestCreate,
RateRequestResponse,
RateRequestUpdate,
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
ServiceRequestResponse,
ServiceRequestUpdate,
)
router = APIRouter()
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
# ----- Solicitudes de servicio (RFQ) -----
@router.get("/service-requests", response_model=list[ServiceRequestResponse])
def list_service_requests(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
req_status: str | None = Query(None, alias="status"),
operation_type: str | None = Query(None),
account_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_service_requests(db, tenant_id, company_id, search, req_status, operation_type, account_id)
@router.get("/service-requests/{request_id}", response_model=ServiceRequestResponse)
def get_service_request(
request_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_service_request(db, request_id, tenant_id, company_id)
@router.post("/service-requests", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED)
def create_service_request(
payload: ServiceRequestCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_service_request(db, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/service-requests/{request_id}", response_model=ServiceRequestResponse)
def update_service_request(
request_id: int,
payload: ServiceRequestUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.update_service_request(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/from-opportunity", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED)
def create_from_opportunity(
payload: ServiceRequestFromOpportunityInput,
opportunity_id: int = Query(..., description="Oportunidad a convertir en solicitud"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Convierte una oportunidad del embudo en solicitud/RFQ enlazada (R-C-02)."""
tenant_id = current_user["tenant_id"]
return service.create_from_opportunity(db, opportunity_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/{request_id}/contact", response_model=ServiceRequestResponse)
def register_contact(
request_id: int,
payload: ServiceRequestContactInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
tenant_id = current_user["tenant_id"]
return service.register_contact(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/{request_id}/requote", response_model=ServiceRequestResponse)
def reopen_for_requote(
request_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
tenant_id = current_user["tenant_id"]
return service.reopen_for_requote(db, request_id, tenant_id, company_id, _user_id(current_user))
@router.delete("/service-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_service_request(
request_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
service.delete_service_request(db, request_id, tenant_id, company_id)
# ----- Solicitudes de tarifa -----
@router.get("/rate-requests", response_model=list[RateRequestResponse])
def list_rate_requests(
company_id: int = Query(..., description="Company ID"),
service_request_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_rate_requests(db, tenant_id, company_id, service_request_id)
@router.post("/rate-requests", response_model=RateRequestResponse, status_code=status.HTTP_201_CREATED)
def create_rate_request(
payload: RateRequestCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_rate_request(db, payload, tenant_id, company_id)
@router.patch("/rate-requests/{rate_id}", response_model=RateRequestResponse)
def update_rate_request(
rate_id: int,
payload: RateRequestUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.update_rate_request(db, rate_id, payload, tenant_id, company_id)
@router.delete("/rate-requests/{rate_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_rate_request(
rate_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
service.delete_rate_request(db, rate_id, tenant_id, company_id)

View File

@@ -0,0 +1,295 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
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 (
RateRequestCreate,
RateRequestUpdate,
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
ServiceRequestUpdate,
)
from .models import RateRequest, ServiceRequest
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
if _id is None:
return True
return (
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
is not None
)
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):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La oportunidad asociada no existe")
incoterm = data.get("incoterm")
if incoterm and incoterm not in INCOTERM_CODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Incoterm inválido: usa uno del catálogo ({', '.join(sorted(INCOTERM_CODES))})",
)
# ----- Service requests (RFQ) -----
def get_service_requests(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
req_status: str | None = None,
operation_type: str | None = None,
account_id: int | None = None,
) -> list[ServiceRequest]:
query = db.query(ServiceRequest).filter(
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
if req_status:
query = query.filter(ServiceRequest.status == req_status)
if operation_type:
query = query.filter(ServiceRequest.operation_type == operation_type)
if account_id is not None:
query = query.filter(ServiceRequest.account_id == account_id)
if search:
pattern = f"%{search}%"
query = query.filter(
ServiceRequest.reference.ilike(pattern)
| ServiceRequest.origin.ilike(pattern)
| ServiceRequest.destination.ilike(pattern)
)
return query.order_by(ServiceRequest.created_at.desc()).all()
def get_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> ServiceRequest:
obj = (
db.query(ServiceRequest)
.filter(
ServiceRequest.id == request_id,
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
return obj
def create_service_request(
db: Session, payload: ServiceRequestCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> ServiceRequest:
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)
return obj
def update_service_request(
db: Session, request_id: int, payload: ServiceRequestUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> ServiceRequest:
obj = get_service_request(db, request_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_request_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> None:
obj = get_service_request(db, request_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def register_contact(
db: Session, request_id: int, payload: ServiceRequestContactInput, tenant_id: int, company_id: int,
user_id: str | None = None,
) -> ServiceRequest:
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
obj = get_service_request(db, request_id, tenant_id, company_id)
obj.first_contact_at = datetime.now(timezone.utc)
obj.first_contact_notes = payload.notes
if obj.status == "nueva":
obj.status = "contacto"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def create_from_opportunity(
db: Session, opportunity_id: int, payload: ServiceRequestFromOpportunityInput,
tenant_id: int, company_id: int, user_id: str | None = None,
) -> ServiceRequest:
"""Convierte una oportunidad del embudo en una solicitud/RFQ enlazada (R-C-02).
Da continuidad al hilo comercial: el embudo (primer contacto) queda ligado a la
cadena RFQ → cotización → embarque vía ``opportunity_id``.
"""
opp = (
db.query(Opportunity)
.filter(
Opportunity.id == opportunity_id,
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
Opportunity.deleted_at.is_(None),
)
.first()
)
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=operation_type,
transport_mode=payload.transport_mode,
service_type=payload.service_type,
incoterm=payload.incoterm,
origin=payload.origin,
destination=payload.destination,
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
def reopen_for_requote(
db: Session, request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> ServiceRequest:
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
obj = get_service_request(db, request_id, tenant_id, company_id)
if obj.status not in ("rechazada", "cotizada"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Solo una solicitud rechazada o cotizada puede reabrirse para re-cotizar",
)
obj.status = "en_analisis"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
# ----- Rate requests -----
def get_rate_requests(
db: Session, tenant_id: int, company_id: int, service_request_id: int | None = None
) -> list[RateRequest]:
query = db.query(RateRequest).filter(
RateRequest.tenant_id == tenant_id,
RateRequest.company_id == company_id,
RateRequest.deleted_at.is_(None),
)
if service_request_id is not None:
query = query.filter(RateRequest.service_request_id == service_request_id)
return query.order_by(RateRequest.id.asc()).all()
def get_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> RateRequest:
obj = (
db.query(RateRequest)
.filter(
RateRequest.id == rate_id,
RateRequest.tenant_id == tenant_id,
RateRequest.company_id == company_id,
RateRequest.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud de tarifa no encontrada")
return obj
def create_rate_request(db: Session, payload: RateRequestCreate, tenant_id: int, company_id: int) -> RateRequest:
data = payload.model_dump()
# La solicitud de servicio debe existir en el tenant/company
get_service_request(db, data["service_request_id"], tenant_id, company_id)
if not _exists(db, Supplier, data.get("supplier_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
obj = RateRequest(**data, tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_rate_request(
db: Session, rate_id: int, payload: RateRequestUpdate, tenant_id: int, company_id: int
) -> RateRequest:
obj = get_rate_request(db, rate_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
for field, value in data.items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def delete_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> None:
obj = get_rate_request(db, rate_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()

View File

@@ -13,6 +13,7 @@ class SupplierBase(BaseModel):
person_type: str | None = Field(None, max_length=10)
status: str = Field("active", max_length=20)
classifications: list[str] = Field(default_factory=list)
classification_other: str | None = Field(None, max_length=120)
# Comercial
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
@@ -52,6 +53,7 @@ class SupplierUpdate(BaseModel):
person_type: str | None = Field(None, max_length=10)
status: str | None = Field(None, max_length=20)
classifications: list[str] | None = None
classification_other: str | None = Field(None, max_length=120)
services_offered: str | None = None
coverage: str | None = Field(None, max_length=20)
countries: list[str] | None = None

View File

@@ -30,6 +30,8 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin):
# Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril,
# agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro
classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
# Texto libre cuando la clasificación incluye "otro"
classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
# ----- Información comercial -----
services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,63 @@
"""Subida de archivos a MinIO/S3 para documentos del CRM y Operaciones.
Flujo: el frontend sube el archivo aquí, recibe ``file_key`` (permanente) y lo
guarda en el documento (crm.documents / ops.shipment_documents). Para abrirlo se
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 core.security import get_current_user
from core.storage_s3 import presigned_get_url, put_object_bytes
router = APIRouter()
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
def _safe_filename(name: str | None) -> str:
base = (name or "archivo").strip().replace(" ", "_")
base = _SAFE_NAME.sub("", base) or "archivo"
return base[:120]
@router.post("/uploads")
async def upload_file(
file: UploadFile = File(...),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user["tenant_id"]
content = await file.read()
if len(content) > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="El archivo excede el tamaño máximo permitido (25 MB)",
)
filename = _safe_filename(file.filename)
key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}"
put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream")
return {
"file_key": key,
"file_url": presigned_get_url(key),
"name": file.filename,
"content_type": file.content_type,
"size_bytes": len(content),
}
@router.get("/uploads/url")
def get_upload_url(
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),
):
tenant_id = current_user["tenant_id"]
# Un archivo solo puede consultarse dentro de su propio tenant/company (aislamiento).
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")
return {"url": presigned_get_url(key)}

View File

View File

@@ -0,0 +1,121 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, computed_field
class InvoiceClientReviewInput(BaseModel):
"""Resultado de la revisión de la factura por el cliente (R-F-06)."""
approved: bool
notes: str | None = None
class InvoiceItemBase(BaseModel):
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
unit_amount: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
class InvoiceItemCreate(InvoiceItemBase):
invoice_id: int
class InvoiceItemUpdate(BaseModel):
concept: str | None = Field(None, max_length=60)
description: str | None = Field(None, max_length=255)
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
unit_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
class InvoiceItemResponse(InvoiceItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_id: int
tenant_id: int
company_id: int
@computed_field
@property
def line_total(self) -> Decimal:
return (self.quantity or Decimal(0)) * (self.unit_amount or Decimal(0))
class PaymentBase(BaseModel):
amount: Decimal = Field(..., gt=0, max_digits=14, decimal_places=2)
payment_date: date | None = None
method: str | None = Field(None, max_length=40)
reference: str | None = Field(None, max_length=120)
notes: str | None = None
class PaymentCreate(PaymentBase):
invoice_id: int
class PaymentResponse(PaymentBase):
model_config = ConfigDict(from_attributes=True)
id: int
invoice_id: int
tenant_id: int
company_id: int
created_at: datetime
class InvoiceBase(BaseModel):
reference: str | None = Field(None, max_length=40)
shipment_id: int | None = None
quote_id: int | None = None
account_id: int | None = None
currency: str = Field("MXN", max_length=3)
issue_date: date | None = None
due_date: date | None = None
tax_rate: Decimal = Field(Decimal(0), ge=0, le=100, max_digits=5, decimal_places=2)
bank_info: str | None = None
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class InvoiceCreate(InvoiceBase):
pass
class InvoiceUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
shipment_id: int | None = None
quote_id: int | None = None
account_id: int | None = None
currency: str | None = Field(None, max_length=3)
issue_date: date | None = None
due_date: date | None = None
tax_rate: Decimal | None = Field(None, ge=0, le=100, max_digits=5, decimal_places=2)
bank_info: str | None = None
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class InvoiceResponse(InvoiceBase):
model_config = ConfigDict(from_attributes=True)
id: int
status: str
subtotal: Decimal
tax_amount: Decimal
total: Decimal
paid_amount: Decimal
balance: Decimal
ops_cost_total: Decimal | None = None
sent_at: datetime | None = None
paid_at: datetime | None = None
pdf_file_key: str | None = None
client_reviewed_at: datetime | None = None
client_approved: bool | None = None
review_notes: str | None = None
created_by: str | None = None
updated_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,86 @@
from datetime import date, datetime
from sqlalchemy import 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
from core.database import Base
class Invoice(Base, TenantScopedMixin, TimestampMixin):
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
__tablename__ = "invoices"
__table_args__ = {"schema": "fin"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio
shipment_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True
)
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True
)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
# borrador | emitida | enviada | en_revision_cliente | pagada | cancelada
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)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
subtotal: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
tax_rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False, server_default=text("0")) # % IVA
tax_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
total: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
paid_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
balance: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
# Costos reales de la operación traídos de Operaciones al cierre (R-F-02)
ops_cost_total: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
bank_info: Mapped[str | None] = mapped_column(Text, nullable=True) # datos bancarios
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# ----- Envío al cliente (R-F-05): PDF almacenado en MinIO -----
pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
# ----- Revisión del cliente (R-F-06) -----
client_reviewed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
client_approved: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
review_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
"""Concepto de una factura (transporte, flete, despacho, gastos en destino, otros)."""
__tablename__ = "invoice_items"
__table_args__ = {"schema": "fin"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
invoice_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
)
concept: Mapped[str] = mapped_column(String(60), nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
class Payment(Base, TenantScopedMixin, TimestampMixin):
"""Pago (cobranza) aplicado a una factura."""
__tablename__ = "payments"
__table_args__ = {"schema": "fin"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
invoice_id: Mapped[int] = mapped_column(
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
)
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
payment_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# transferencia | efectivo | cheque | tarjeta | otro
method: Mapped[str | None] = mapped_column(String(40), nullable=True)
reference: Mapped[str | None] = mapped_column(String(120), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,186 @@
"""Generador de PDF de factura sin dependencias externas.
Se evita ``pdfkit`` (requiere el binario ``wkhtmltopdf``, ausente en el contenedor)
y librerías extra. Produce un PDF válido de una o varias páginas con la fuente
estándar Helvetica (no requiere incrustar fuentes). El texto se codifica en
WinAnsi/Latin-1; los caracteres fuera de ese rango se sustituyen para no romper
el flujo de contenido.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Sequence
_PAGE_W = 612 # carta (8.5in) en puntos
_PAGE_H = 792 # carta (11in)
_MARGIN = 56
_LINE_H = 16
_LINES_PER_PAGE = 42
def _esc(text: str) -> str:
"""Escapa y codifica una cadena para un literal de texto PDF (WinAnsi)."""
out = (text or "").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} {d:,.2f}"
def _wrap(text: str, width: int) -> list[str]:
text = text or ""
words = text.split()
if not words:
return [""]
lines: list[str] = []
current = ""
for word in words:
candidate = f"{current} {word}".strip()
if len(candidate) > width and current:
lines.append(current)
current = word
else:
current = candidate
if current:
lines.append(current)
return lines
def _build_lines(
*,
folio: str,
issue_date: str,
due_date: str,
account_name: str,
currency: str,
items: Sequence[dict],
subtotal,
tax_rate,
tax_amount,
total,
paid,
balance,
bank_info: str | None,
notes: str | None,
) -> list[tuple[str, int]]:
"""Devuelve una lista de (texto, tamaño_fuente) que compone el cuerpo."""
L: list[tuple[str, int]] = []
L.append(("FACTURA", 20))
L.append((f"Folio: {folio or 's/f'}", 11))
L.append((f"Fecha de emision: {issue_date or '-'} Vencimiento: {due_date or '-'}", 11))
L.append(("", 11))
L.append((f"Cliente: {account_name or '-'}", 12))
L.append(("", 11))
L.append(("Conceptos", 13))
L.append(("-" * 78, 10))
L.append(("Cant. Concepto P. unitario Importe", 10))
L.append(("-" * 78, 10))
for it in items:
concept = str(it.get("concept") or "")
desc = str(it.get("description") or "")
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_amount") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
label = concept if not desc else f"{concept}{desc}"
label = label[:42].ljust(42)
row = f"{qty:>5.2f} {label} {unit:>12,.2f} {amount:>12,.2f}"
L.append((row, 10))
L.append(("-" * 78, 10))
L.append(("", 11))
L.append((f"Subtotal: {_money(subtotal, currency)}", 11))
L.append((f"IVA ({Decimal(str(tax_rate or 0)):.2f}%): {_money(tax_amount, currency)}", 11))
L.append((f"Total: {_money(total, currency)}", 13))
L.append((f"Pagado: {_money(paid, currency)}", 11))
L.append((f"Saldo: {_money(balance, currency)}", 12))
if bank_info:
L.append(("", 11))
L.append(("Datos bancarios / de pago", 12))
for line in _wrap(bank_info, 90):
L.append((line, 10))
if notes:
L.append(("", 11))
L.append(("Notas", 12))
for line in _wrap(notes, 90):
L.append((line, 10))
return L
def build_invoice_pdf(**kwargs) -> bytes:
"""Construye el PDF de la factura y devuelve los bytes."""
lines = _build_lines(**kwargs)
# Paginar el cuerpo
pages: list[list[tuple[str, int]]] = []
for i in range(0, len(lines), _LINES_PER_PAGE):
pages.append(lines[i : i + _LINES_PER_PAGE])
if not pages:
pages = [[("FACTURA", 20)]]
# Un content stream por página
content_streams: list[bytes] = []
for page_lines in pages:
parts = ["BT", f"/F1 11 Tf", f"1 0 0 1 {_MARGIN} {_PAGE_H - _MARGIN} Tm", f"{_LINE_H} TL"]
first = True
for text, size in page_lines:
parts.append(f"/F1 {size} Tf")
if first:
parts.append(f"({_esc(text)}) Tj")
first = False
else:
parts.append(f"T* ({_esc(text)}) Tj")
parts.append("ET")
content_streams.append("\n".join(parts).encode("latin-1", "replace"))
# Ensamblado de objetos PDF
objects: list[bytes] = []
def add(obj: bytes) -> int:
objects.append(obj)
return len(objects) # número de objeto (1-indexado)
# Reservamos números: catalog(1), pages(2), font(3), luego páginas y streams
font_obj_num = 3
page_obj_nums: list[int] = []
content_obj_nums: list[int] = []
# Precalcular números de páginas y streams
next_num = 4
for _ in pages:
page_obj_nums.append(next_num)
next_num += 1
for _ in pages:
content_obj_nums.append(next_num)
next_num += 1
kids = " ".join(f"{n} 0 R" for n in page_obj_nums)
add(f"<< /Type /Catalog /Pages 2 0 R >>".encode("latin-1"))
add(f"<< /Type /Pages /Kids [{kids}] /Count {len(pages)} >>".encode("latin-1"))
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
for i, _ in enumerate(pages):
page_dict = (
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_PAGE_W} {_PAGE_H}] "
f"/Resources << /Font << /F1 {font_obj_num} 0 R >> >> "
f"/Contents {content_obj_nums[i]} 0 R >>"
)
add(page_dict.encode("latin-1"))
for stream in content_streams:
obj = b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream"
add(obj)
# Serialización con tabla xref
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)
n = len(objects) + 1
out += f"xref\n0 {n}\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 {n} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")
return bytes(out)

View File

@@ -0,0 +1,134 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import (
InvoiceClientReviewInput,
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemResponse,
InvoiceItemUpdate,
InvoiceResponse,
InvoiceUpdate,
PaymentCreate,
PaymentResponse,
)
router = APIRouter()
def _uid(cu: dict) -> str | None:
return cu.get("sub") or cu.get("id")
@router.get("/invoices", response_model=list[InvoiceResponse])
def list_invoices(
company_id: int = Query(...),
search: str | None = Query(None),
inv_status: str | None = Query(None, alias="status"),
account_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_invoices(db, current_user["tenant_id"], company_id, search, inv_status, account_id)
@router.get("/invoices/{invoice_id}", response_model=InvoiceResponse)
def get_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.get_invoice(db, invoice_id, current_user["tenant_id"], company_id)
@router.post("/invoices", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
def create_invoice(payload: InvoiceCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.create_invoice(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.post("/invoices/from-shipment", response_model=InvoiceResponse, status_code=status.HTTP_201_CREATED)
def generate_from_shipment(shipment_id: int = Query(...), company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.generate_from_shipment(db, shipment_id, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}", response_model=InvoiceResponse)
def update_invoice(invoice_id: int, payload: InvoiceUpdate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.update_invoice(db, invoice_id, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}/emit", response_model=InvoiceResponse)
def emit_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.emit_invoice(db, invoice_id, current_user["tenant_id"], company_id)
@router.patch("/invoices/{invoice_id}/send", response_model=InvoiceResponse)
def send_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""Genera el PDF, lo guarda en MinIO y marca la factura como enviada (R-F-05)."""
return service.send_invoice(db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user))
@router.get("/invoices/{invoice_id}/pdf-url")
def get_invoice_pdf_url(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""URL firmada fresca del PDF de la factura (R-F-05)."""
return {"url": service.get_invoice_pdf_url(db, invoice_id, current_user["tenant_id"], company_id)}
@router.patch("/invoices/{invoice_id}/client-review", response_model=InvoiceResponse)
def mark_client_review(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""Marca la factura en revisión del cliente (R-F-06)."""
return service.mark_client_review(db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}/client-decision", response_model=InvoiceResponse)
def client_review_decision(invoice_id: int, payload: InvoiceClientReviewInput, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
"""Registra la decisión del cliente sobre la factura: aprobada o con observaciones (R-F-06)."""
return service.client_review_decision(db, invoice_id, payload, current_user["tenant_id"], company_id, _uid(current_user))
@router.patch("/invoices/{invoice_id}/cancel", response_model=InvoiceResponse)
def cancel_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.cancel_invoice(db, invoice_id, current_user["tenant_id"], company_id)
@router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_invoice(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_invoice(db, invoice_id, current_user["tenant_id"], company_id)
# ----- Conceptos -----
@router.get("/invoices/{invoice_id}/items", response_model=list[InvoiceItemResponse])
def list_items(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.get_items(db, invoice_id, current_user["tenant_id"], company_id)
@router.post("/invoice-items", response_model=InvoiceItemResponse, status_code=status.HTTP_201_CREATED)
def create_item(payload: InvoiceItemCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.create_item(db, payload, current_user["tenant_id"], company_id)
@router.patch("/invoice-items/{item_id}", response_model=InvoiceItemResponse)
def update_item(item_id: int, payload: InvoiceItemUpdate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.update_item(db, item_id, payload, current_user["tenant_id"], company_id)
@router.delete("/invoice-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_item(db, item_id, current_user["tenant_id"], company_id)
# ----- Pagos (cobranza) -----
@router.get("/invoices/{invoice_id}/payments", response_model=list[PaymentResponse])
def list_payments(invoice_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.get_payments(db, invoice_id, current_user["tenant_id"], company_id)
@router.post("/payments", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
def create_payment(payload: PaymentCreate, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
return service.create_payment(db, payload, current_user["tenant_id"], company_id)
@router.delete("/payments/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_payment(payment_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
service.delete_payment(db, payment_id, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,397 @@
from datetime import date, datetime, timezone
from decimal import Decimal
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.ops.shipments.models import Shipment
from .dto import (
InvoiceClientReviewInput,
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemUpdate,
InvoiceUpdate,
PaymentCreate,
)
from .models import Invoice, InvoiceItem, Payment
from .pdf import build_invoice_pdf
def _exists(db: Session, model, _id, tenant_id, company_id) -> bool:
if _id is None:
return True
return (
db.query(model.id)
.filter(model.id == _id, model.tenant_id == tenant_id, model.company_id == company_id, model.deleted_at.is_(None))
.first()
is not None
)
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
for field, model, msg in [
("account_id", Account, "El cliente asociado no existe"),
("shipment_id", Shipment, "El embarque asociado no existe"),
("quote_id", Quote, "La cotización asociada no existe"),
]:
if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
def _recompute(db: Session, invoice: Invoice) -> None:
subtotal = db.query(func.coalesce(func.sum(InvoiceItem.quantity * InvoiceItem.unit_amount), 0)).filter(
InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None)
).scalar()
paid = db.query(func.coalesce(func.sum(Payment.amount), 0)).filter(
Payment.invoice_id == invoice.id, Payment.deleted_at.is_(None)
).scalar()
subtotal = Decimal(subtotal or 0)
rate = Decimal(invoice.tax_rate or 0)
tax = (subtotal * rate / Decimal(100)).quantize(Decimal("0.01"))
total = subtotal + tax
paid = Decimal(paid or 0)
invoice.subtotal = subtotal
invoice.tax_amount = tax
invoice.total = total
invoice.paid_amount = paid
invoice.balance = total - paid
# Estado de cobranza (no toca borrador ni cancelada)
if invoice.status in ("emitida", "enviada", "en_revision_cliente", "pagada"):
if total > 0 and invoice.balance <= 0:
invoice.status = "pagada"
invoice.paid_at = datetime.now(timezone.utc)
elif invoice.status == "pagada" and invoice.balance > 0:
invoice.status = "enviada"
invoice.paid_at = None
# ----- Invoices -----
def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]:
q = db.query(Invoice).filter(Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None))
if inv_status:
q = q.filter(Invoice.status == inv_status)
if account_id is not None:
q = q.filter(Invoice.account_id == account_id)
if search:
q = q.filter(Invoice.reference.ilike(f"%{search}%"))
return q.order_by(Invoice.created_at.desc()).all()
def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
obj = db.query(Invoice).filter(
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None)
).first()
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
return obj
def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=None) -> Invoice:
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)
db.add(obj)
db.flush()
_recompute(db, obj)
db.commit()
db.refresh(obj)
return obj
def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id, user_id=None) -> Invoice:
obj = get_invoice(db, invoice_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
for f, v in data.items():
setattr(obj, f, v)
obj.updated_by = user_id
db.flush()
_recompute(db, obj) # tax_rate pudo cambiar
db.commit()
db.refresh(obj)
return obj
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
obj = get_invoice(db, invoice_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def _set_status(db, invoice_id, tenant_id, company_id, new_status, set_issue=False) -> Invoice:
obj = get_invoice(db, invoice_id, tenant_id, company_id)
obj.status = new_status
if set_issue and not obj.issue_date:
obj.issue_date = date.today()
if new_status == "enviada":
obj.sent_at = datetime.now(timezone.utc)
db.commit()
db.refresh(obj)
return obj
def emit_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
return _set_status(db, invoice_id, tenant_id, company_id, "emitida", set_issue=True)
def _build_pdf_bytes(db, invoice: Invoice, tenant_id, company_id) -> bytes:
"""Arma los bytes del PDF de la factura a partir de sus datos y conceptos."""
items = get_items(db, invoice.id, tenant_id, company_id)
account_name = None
if invoice.account_id:
acc = db.query(Account).filter(Account.id == invoice.account_id).first()
account_name = acc.name if acc else None
return build_invoice_pdf(
folio=invoice.reference or f"FAC-{invoice.id}",
issue_date=str(invoice.issue_date or ""),
due_date=str(invoice.due_date or ""),
account_name=account_name or "Cliente",
currency=invoice.currency or "MXN",
items=[
{"concept": it.concept, "description": it.description, "quantity": it.quantity, "unit_amount": it.unit_amount}
for it in items
],
subtotal=invoice.subtotal,
tax_rate=invoice.tax_rate,
tax_amount=invoice.tax_amount,
total=invoice.total,
paid=invoice.paid_amount,
balance=invoice.balance,
bank_info=invoice.bank_info,
notes=invoice.notes,
)
def send_invoice(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
"""Envía la factura al cliente: genera el PDF, lo guarda en MinIO y marca 'enviada' (R-F-05)."""
from core.storage_s3 import put_object_bytes # import diferido: evita conectar en tests
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if obj.status in ("borrador", "cancelada"):
# La factura debe estar emitida antes de enviarse al cliente
if obj.status == "cancelada":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada")
obj.status = "emitida"
if not obj.issue_date:
obj.issue_date = date.today()
db.flush()
pdf_bytes = _build_pdf_bytes(db, obj, tenant_id, company_id)
key = f"tenants/{tenant_id}/companies/{company_id}/fin-invoices/{obj.id}/factura-{obj.reference or obj.id}.pdf"
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
obj.pdf_file_key = key
obj.status = "enviada"
obj.sent_at = datetime.now(timezone.utc)
if not obj.issue_date:
obj.issue_date = date.today()
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def get_invoice_pdf_url(db, invoice_id, tenant_id, company_id) -> str:
"""Devuelve una URL firmada fresca del PDF de la factura (las presignadas expiran)."""
from core.storage_s3 import presigned_get_url
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if not obj.pdf_file_key:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="La factura aún no tiene PDF; envíala al cliente para generarlo",
)
return presigned_get_url(obj.pdf_file_key)
def mark_client_review(db, invoice_id, tenant_id, company_id, user_id=None) -> Invoice:
"""Pone la factura en revisión del cliente (R-F-06)."""
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if obj.status not in ("enviada", "en_revision_cliente"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Solo una factura enviada puede pasar a revisión del cliente",
)
obj.status = "en_revision_cliente"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def client_review_decision(
db, invoice_id, payload: InvoiceClientReviewInput, tenant_id, company_id, user_id=None
) -> Invoice:
"""Registra la decisión de revisión del cliente: aprobada o con observaciones (R-F-06)."""
obj = get_invoice(db, invoice_id, tenant_id, company_id)
if obj.status not in ("enviada", "en_revision_cliente"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="La factura debe estar enviada o en revisión para registrar la decisión del cliente",
)
obj.client_reviewed_at = datetime.now(timezone.utc)
obj.client_approved = payload.approved
obj.review_notes = payload.notes
# Aprobada → lista para cobranza (enviada). Con observaciones → regresa a emitida para corregir.
obj.status = "enviada" if payload.approved else "emitida"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def cancel_invoice(db, invoice_id, tenant_id, company_id) -> Invoice:
return _set_status(db, invoice_id, tenant_id, company_id, "cancelada")
def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None) -> Invoice:
"""Genera la factura de un embarque, tomando los conceptos (venta) de su cotización.
El disparador válido de la facturación es el cierre operativo del embarque
(R-F-01): solo se factura un embarque en estado 'cerrada'. Los costos reales de
la operación se arrastran a la factura (R-F-02).
"""
shipment = db.query(Shipment).filter(
Shipment.id == shipment_id, Shipment.tenant_id == tenant_id, Shipment.company_id == company_id, Shipment.deleted_at.is_(None)
).first()
if not shipment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
if shipment.status != "cerrada":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El embarque debe estar cerrado (cierre operativo) para facturarse",
)
existing = db.query(Invoice).filter(
Invoice.shipment_id == shipment_id, Invoice.tenant_id == tenant_id,
Invoice.company_id == company_id, Invoice.deleted_at.is_(None),
Invoice.status != "cancelada",
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="El embarque ya tiene una factura vigente",
)
quote = None
if shipment.quote_id:
quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first()
invoice = Invoice(
reference=shipment.reference,
shipment_id=shipment.id,
quote_id=shipment.quote_id,
account_id=shipment.account_id,
currency=(shipment.cost_currency or (quote.currency if quote else "MXN")),
ops_cost_total=shipment.actual_cost_total,
status="borrador",
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(invoice)
db.flush()
if quote:
q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all()
for qi in q_items:
db.add(InvoiceItem(
invoice_id=invoice.id, concept=qi.concept, description=qi.description,
quantity=qi.quantity, unit_amount=qi.unit_sale,
tenant_id=tenant_id, company_id=company_id,
))
db.flush()
_recompute(db, invoice)
db.commit()
db.refresh(invoice)
return invoice
# ----- Items -----
def get_items(db, invoice_id, tenant_id, company_id) -> list[InvoiceItem]:
get_invoice(db, invoice_id, tenant_id, company_id)
return db.query(InvoiceItem).filter(
InvoiceItem.invoice_id == invoice_id, InvoiceItem.tenant_id == tenant_id,
InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
).order_by(InvoiceItem.id.asc()).all()
def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
obj = db.query(InvoiceItem).filter(
InvoiceItem.id == item_id, InvoiceItem.tenant_id == tenant_id,
InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None)
).first()
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
return obj
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(item)
db.flush()
_recompute(db, invoice)
db.commit()
db.refresh(item)
return item
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
item = _get_item(db, item_id, tenant_id, company_id)
for f, v in payload.model_dump(exclude_unset=True).items():
setattr(item, f, v)
db.flush()
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
db.commit()
db.refresh(item)
return item
def delete_item(db, item_id, tenant_id, company_id) -> None:
item = _get_item(db, item_id, tenant_id, company_id)
invoice_id = item.invoice_id
item.deleted_at = datetime.now(timezone.utc)
db.flush()
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
db.commit()
# ----- Payments -----
def get_payments(db, invoice_id, tenant_id, company_id) -> list[Payment]:
get_invoice(db, invoice_id, tenant_id, company_id)
return db.query(Payment).filter(
Payment.invoice_id == invoice_id, Payment.tenant_id == tenant_id,
Payment.company_id == company_id, Payment.deleted_at.is_(None)
).order_by(Payment.id.asc()).all()
def create_payment(db, payload: PaymentCreate, tenant_id, company_id) -> Payment:
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
pay = Payment(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(pay)
db.flush()
_recompute(db, invoice)
db.commit()
db.refresh(pay)
return pay
def delete_payment(db, payment_id, tenant_id, company_id) -> None:
pay = db.query(Payment).filter(
Payment.id == payment_id, Payment.tenant_id == tenant_id,
Payment.company_id == company_id, Payment.deleted_at.is_(None)
).first()
if not pay:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pago no encontrado")
invoice_id = pay.invoice_id
pay.deleted_at = datetime.now(timezone.utc)
db.flush()
_recompute(db, get_invoice(db, invoice_id, tenant_id, company_id))
db.commit()

View File

@@ -0,0 +1,17 @@
"""Registro de permisos del módulo Facturación (fin)."""
from api.v1.modules.core.permissions.registry import registry
MODULE = "fin"
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
def register_permissions() -> None:
registry.register(code=f"{MODULE}.access", description="Acceso a Facturación", module=MODULE, action="access")
for entity, label in _ENTITIES:
for action, verb in _ACTIONS:
registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action)
register_permissions()

View File

@@ -0,0 +1,12 @@
"""Router agregador del módulo Facturación (Diagrama 4). Prefijo ``/fin``."""
from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from . import permissions # noqa: F401 (side-effect: registra permisos)
from .invoices.routes import router as invoices_router
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
router.include_router(invoices_router)

View File

View File

@@ -0,0 +1,26 @@
"""Registro de permisos del módulo Operaciones (ops)."""
from api.v1.modules.core.permissions.registry import registry
MODULE = "ops"
_ENTITIES = [
("shipment", "embarques"),
("document", "documentos de embarque"),
]
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
def register_permissions() -> None:
registry.register(code=f"{MODULE}.access", description="Acceso a Operaciones", module=MODULE, action="access")
for entity, label in _ENTITIES:
for action, verb in _ACTIONS:
registry.register(
code=f"{MODULE}.{entity}.{action}",
description=f"{verb} {label}",
module=MODULE,
action=action,
)
register_permissions()

View File

@@ -0,0 +1,16 @@
"""Router agregador del módulo Operaciones (Diagramas 2-4).
Se monta bajo el prefijo ``/ops`` en ``api/v1/router.py``.
"""
from fastapi import APIRouter, Depends
from api.v1.modules.core.permissions.dependencies import PermissionChecker
from . import permissions # noqa: F401 (side-effect: registra permisos de ops)
from .shipments.routes import router as shipments_router
# Enforcement por área/carril (R-T-07): se exige ops.access para el módulo.
router = APIRouter(dependencies=[Depends(PermissionChecker(["ops.access"]))])
router.include_router(shipments_router)

View File

@@ -0,0 +1,173 @@
from datetime import date, datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class ShipmentBase(BaseModel):
reference: str | None = Field(None, max_length=40)
quote_id: int | None = None
service_request_id: int | None = None
account_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
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)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
status: str = Field("abierta", max_length=20)
booking_number: str | None = Field(None, max_length=60)
carrier_supplier_id: int | None = None
ground_carrier_supplier_id: int | None = None
customs_agent_id: int | None = None
destination_agent_id: int | None = None
cutoff_date: datetime | None = None
pickup_at: datetime | None = None
etd: date | None = None
previous_etd: date | None = None
eta: date | None = None
vessel_flight: str | None = Field(None, max_length=120)
container_number: str | None = Field(None, max_length=60)
notes: str | None = None
actual_cost_total: Decimal | None = None
cost_currency: str | None = Field(None, max_length=3)
owner_user_id: str | None = Field(None, max_length=64)
class ShipmentCreate(ShipmentBase):
pass
class ShipmentUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
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)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
status: str | None = Field(None, max_length=20)
booking_number: str | None = Field(None, max_length=60)
carrier_supplier_id: int | None = None
ground_carrier_supplier_id: int | None = None
customs_agent_id: int | None = None
destination_agent_id: int | None = None
cutoff_date: datetime | None = None
pickup_at: datetime | None = None
etd: date | None = None
eta: date | None = None
vessel_flight: str | None = Field(None, max_length=120)
container_number: str | None = Field(None, max_length=60)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class ShipmentRescheduleInput(BaseModel):
"""Reprogramación de salida cuando no se alcanza el Cut Off (R-E-06)."""
etd: date | None = None
cutoff_date: datetime | None = None
reason: str | None = None
class ShipmentCloseInput(BaseModel):
"""Cierre operativo del embarque con costos finales (R-E-22)."""
actual_cost_total: Decimal = Field(..., ge=0)
cost_currency: str = Field("MXN", max_length=3)
notes: str | None = None
class ShipmentResponse(ShipmentBase):
model_config = ConfigDict(from_attributes=True)
id: int
closed_at: datetime | None = None
closed_by: str | None = None
created_by: str | None = None
updated_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class ShipmentEventBase(BaseModel):
shipment_id: int
event_type: str | None = Field(None, max_length=60)
title: str = Field(..., min_length=1, max_length=160)
kind: str = Field("hito", max_length=20) # hito | decision
status: str = Field("pendiente", max_length=20)
outcome: str | None = Field(None, max_length=20) # autorizado | rechazado
parent_event_id: int | None = None
attempt: int = Field(1, ge=1)
position: int = Field(0, ge=0)
planned_date: datetime | None = None
actual_date: datetime | None = None
notes: str | None = None
class ShipmentEventCreate(ShipmentEventBase):
pass
class ShipmentEventUpdate(BaseModel):
event_type: str | None = Field(None, max_length=60)
title: str | None = Field(None, min_length=1, max_length=160)
kind: str | None = Field(None, max_length=20)
status: str | None = Field(None, max_length=20)
position: int | None = Field(None, ge=0)
planned_date: datetime | None = None
actual_date: datetime | None = None
notes: str | None = None
class ShipmentEventDecisionInput(BaseModel):
"""Resultado de un punto de decisión del flujo (R-E-13, R-E-05, R-I-06)."""
outcome: Literal["autorizado", "rechazado"]
notes: str | None = None
class ShipmentEventResponse(ShipmentEventBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class ShipmentDocumentBase(BaseModel):
shipment_id: int
doc_kind: str = Field("otro", max_length=10)
doc_type: str = Field(..., max_length=30)
number: str | None = Field(None, max_length=80)
issue_date: date | None = None
file_url: str | None = Field(None, max_length=1024)
file_key: str | None = Field(None, max_length=512)
notes: str | None = None
class ShipmentDocumentCreate(ShipmentDocumentBase):
pass
class ShipmentDocumentUpdate(BaseModel):
doc_kind: str | None = Field(None, max_length=10)
doc_type: str | None = Field(None, max_length=30)
number: str | None = Field(None, max_length=80)
issue_date: date | None = None
file_url: str | None = Field(None, max_length=1024)
file_key: str | None = Field(None, max_length=512)
notes: str | None = None
class ShipmentDocumentResponse(ShipmentDocumentBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,112 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Shipment(Base, TenantScopedMixin, TimestampMixin):
"""Operación / Embarque (Diagrama 2). Se crea al liberar una cotización aceptada."""
__tablename__ = "shipments"
__table_args__ = {"schema": "ops"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
)
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True
)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True)
origin: Mapped[str | None] = mapped_column(String(160), nullable=True)
destination: Mapped[str | None] = mapped_column(String(160), nullable=True)
# abierta | booking | en_transito | arribado | entregada | cerrada | cancelada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierta'"), index=True)
booking_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
carrier_supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
) # naviera / aerolínea / transportista principal
ground_carrier_supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
) # transporte terrestre / recolección (R-E-07)
customs_agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
) # agente aduanal
destination_agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
) # agente corresponsal en destino
cutoff_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cut Off
pickup_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # cita/ventana de recolección (R-E-07)
etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida estimada
previous_etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida previa tras reprogramación (R-E-06)
eta: Mapped[date | None] = mapped_column(Date, nullable=True) # llegada estimada
vessel_flight: Mapped[str | None] = mapped_column(String(120), nullable=True) # buque / vuelo
container_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# ----- Cierre operativo (R-E-22 / disparador de facturación R-F-01) -----
actual_cost_total: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) # costos finales reales
cost_currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # cierre operativo
closed_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
"""Hito / bitácora del embarque (Diagramas 2 y 3). Timeline de la operación."""
__tablename__ = "shipment_events"
__table_args__ = {"schema": "ops"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
shipment_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
)
event_type: Mapped[str | None] = mapped_column(String(60), nullable=True) # clave del hito
title: Mapped[str] = mapped_column(String(160), nullable=False)
# hito | decision — un 'decision' es un punto de decisión del diagrama (rombo)
kind: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'hito'"))
# pendiente | completado | omitido | rechazado | en_correccion
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pendiente'"))
# Resultado de un punto de decisión: autorizado | rechazado (NULL mientras está pendiente)
outcome: Mapped[str | None] = mapped_column(String(20), nullable=True)
# Ciclo de corrección: el hito de re-trámite apunta a la decisión rechazada que lo originó
parent_event_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("ops.shipment_events.id"), nullable=True
)
attempt: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) # número de intento
position: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
planned_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
actual_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""
__tablename__ = "shipment_documents"
__table_args__ = {"schema": "ops"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
shipment_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
)
doc_kind: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text("'otro'")) # master|house|otro
# MBL | HBL | MAWB | HAWB | CMR | factura_comercial | packing_list | carta_encomienda | carta_garantia | otro
doc_type: Mapped[str] = mapped_column(String(30), nullable=False)
number: Mapped[str | None] = mapped_column(String(80), nullable=True)
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,243 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import (
ShipmentCloseInput,
ShipmentCreate,
ShipmentDocumentCreate,
ShipmentDocumentResponse,
ShipmentDocumentUpdate,
ShipmentEventCreate,
ShipmentEventDecisionInput,
ShipmentEventResponse,
ShipmentEventUpdate,
ShipmentRescheduleInput,
ShipmentResponse,
ShipmentUpdate,
)
router = APIRouter()
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@router.get("/shipments", response_model=list[ShipmentResponse])
def list_shipments(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
shipment_status: str | None = Query(None, alias="status"),
account_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_shipments(db, tenant_id, company_id, search, shipment_status, account_id)
@router.get("/shipments/{shipment_id}", response_model=ShipmentResponse)
def get_shipment(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
@router.post("/shipments", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
def create_shipment(
payload: ShipmentCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_shipment(db, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/shipments/from-quote", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
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), operation_type=operation_type
)
@router.post("/shipments/{shipment_id}/reschedule", response_model=ShipmentResponse)
def reschedule_shipment(
shipment_id: int,
payload: ShipmentRescheduleInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Reprograma la salida cuando no se alcanza el Cut Off (R-E-06)."""
tenant_id = current_user["tenant_id"]
return service.reschedule_departure(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/shipments/{shipment_id}/close", response_model=ShipmentResponse)
def close_shipment(
shipment_id: int,
payload: ShipmentCloseInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Cierre operativo del embarque con costos finales (R-E-22, dispara facturación R-F-01)."""
tenant_id = current_user["tenant_id"]
return service.close_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/shipments/{shipment_id}", response_model=ShipmentResponse)
def update_shipment(
shipment_id: int,
payload: ShipmentUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.update_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
@router.delete("/shipments/{shipment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shipment(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_shipment(db, shipment_id, current_user["tenant_id"], company_id)
# ----- Documentos del embarque -----
@router.get("/shipments/{shipment_id}/documents", response_model=list[ShipmentDocumentResponse])
def list_shipment_documents(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
return service.get_shipment_documents(db, current_user["tenant_id"], company_id, shipment_id)
@router.post("/shipment-documents", response_model=ShipmentDocumentResponse, status_code=status.HTTP_201_CREATED)
def create_shipment_document(
payload: ShipmentDocumentCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_shipment_document(db, payload, current_user["tenant_id"], company_id)
@router.patch("/shipment-documents/{doc_id}", response_model=ShipmentDocumentResponse)
def update_shipment_document(
doc_id: int,
payload: ShipmentDocumentUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_shipment_document(db, doc_id, payload, current_user["tenant_id"], company_id)
@router.delete("/shipment-documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shipment_document(
doc_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_shipment_document(db, doc_id, current_user["tenant_id"], company_id)
# ----- Bitácora / hitos -----
@router.get("/shipments/{shipment_id}/events", response_model=list[ShipmentEventResponse])
def list_shipment_events(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
return service.get_shipment_events(db, current_user["tenant_id"], company_id, shipment_id)
@router.post("/shipments/{shipment_id}/events/seed", response_model=list[ShipmentEventResponse])
def seed_shipment_events(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.seed_default_milestones(db, shipment_id, current_user["tenant_id"], company_id)
@router.post("/shipment-events", response_model=ShipmentEventResponse, status_code=status.HTTP_201_CREATED)
def create_shipment_event(
payload: ShipmentEventCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_shipment_event(db, payload, current_user["tenant_id"], company_id)
@router.patch("/shipment-events/{event_id}", response_model=ShipmentEventResponse)
def update_shipment_event(
event_id: int,
payload: ShipmentEventUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_shipment_event(db, event_id, payload, current_user["tenant_id"], company_id)
@router.patch("/shipment-events/{event_id}/complete", response_model=ShipmentEventResponse)
def complete_shipment_event(
event_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.complete_shipment_event(db, event_id, current_user["tenant_id"], company_id)
@router.patch("/shipment-events/{event_id}/decision", response_model=ShipmentEventResponse)
def decide_shipment_event(
event_id: int,
payload: ShipmentEventDecisionInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Resuelve un punto de decisión: autorizado o rechazado (abre corrección). R-E-13/R-I-06."""
return service.decide_shipment_event(db, event_id, payload, current_user["tenant_id"], company_id)
@router.delete("/shipment-events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shipment_event(
event_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_shipment_event(db, event_id, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,549 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
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,
ShipmentDocumentCreate,
ShipmentDocumentUpdate,
ShipmentEventCreate,
ShipmentEventDecisionInput,
ShipmentEventUpdate,
ShipmentRescheduleInput,
ShipmentUpdate,
)
from .models import Shipment, ShipmentDocument, ShipmentEvent
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3).
# Tupla: (event_type, título, kind). kind="decision" son puntos de decisión (rombos)
# que se resuelven con autorizado/rechazado y disparan el ciclo de corrección.
_DEFAULT_MILESTONES = {
# Diagrama 2 — Proceso operativo de exportación
"exportacion": [
("coordinacion_fecha_cliente", "Coordinar fecha de operación con el cliente", "hito"),
("revision_salidas", "Revisar disponibilidad de salidas del transporte", "hito"),
("validacion_cutoff", "Validar Cut Off del transportista", "hito"),
("decision_cutoff", "¿Se alcanza el Cut Off?", "decision"),
("programacion_transporte_terrestre", "Programar transporte terrestre y recolección", "hito"),
("recoleccion", "Recolección de mercancía", "hito"),
("traslado_puerto", "Trasladar la mercancía al puerto / aeropuerto", "hito"),
("entrega_terminal", "Entregar la mercancía en la terminal", "hito"),
("entrega_docs_agente", "Entregar documentación al agente aduanal", "hito"),
("despacho_exportacion", "Despacho de exportación", "hito"),
("decision_despacho_exportacion", "¿Despacho de exportación autorizado?", "decision"),
("emision_docs_internacionales", "Emitir documentación internacional (MBL/HBL, MAWB/HAWB, CMR)", "hito"),
("embarque", "Embarque", "hito"),
("zarpe", "Zarpe / Salida del transporte", "hito"),
("coordinacion_corresponsal", "Coordinar con el agente corresponsal en destino", "hito"),
("arribo", "Arribo a destino", "hito"),
("despacho_destino", "Despacho de importación en destino (corresponsal)", "hito"),
("entrega", "Entrega al consignatario", "hito"),
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
],
# Diagrama 3 — Proceso de importación
"importacion": [
("aviso_llegada", "Aviso de llegada", "hito"),
("recepcion_docs", "Recepción de documentos (MBL/MAWB)", "hito"),
("coordinacion_agente_aduanal", "Coordinar con el agente aduanal el despacho", "hito"),
("entrega_docs_agente", "Entregar documentos y requisitos al agente aduanal", "hito"),
("despacho_importacion", "Despacho de importación", "hito"),
("decision_despacho_importacion", "¿Despacho de importación autorizado?", "decision"),
("liberacion", "Liberación de mercancía", "hito"),
("retiro", "Retiro en puerto / aeropuerto", "hito"),
("traslado", "Traslado a bodega del importador", "hito"),
("entrega", "Entrega final al cliente", "hito"),
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
],
}
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
if _id is None:
return True
return (
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
is not None
)
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
checks = [
("account_id", Account, "El cliente asociado no existe"),
("quote_id", Quote, "La cotización asociada no existe"),
("service_request_id", ServiceRequest, "La solicitud asociada no existe"),
("carrier_supplier_id", Supplier, "El transportista/naviera no existe"),
("ground_carrier_supplier_id", Supplier, "El transportista terrestre no existe"),
("customs_agent_id", Supplier, "El agente aduanal no existe"),
("destination_agent_id", Supplier, "El agente en destino no existe"),
]
for field, model, msg in checks:
if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
def get_shipments(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
shipment_status: str | None = None,
account_id: int | None = None,
) -> list[Shipment]:
query = db.query(Shipment).filter(
Shipment.tenant_id == tenant_id,
Shipment.company_id == company_id,
Shipment.deleted_at.is_(None),
)
if shipment_status:
query = query.filter(Shipment.status == shipment_status)
if account_id is not None:
query = query.filter(Shipment.account_id == account_id)
if search:
pattern = f"%{search}%"
query = query.filter(
Shipment.reference.ilike(pattern)
| Shipment.booking_number.ilike(pattern)
| Shipment.origin.ilike(pattern)
| Shipment.destination.ilike(pattern)
)
return query.order_by(Shipment.created_at.desc()).all()
def get_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> Shipment:
obj = (
db.query(Shipment)
.filter(
Shipment.id == shipment_id,
Shipment.tenant_id == tenant_id,
Shipment.company_id == company_id,
Shipment.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
return obj
def create_shipment(
db: Session, payload: ShipmentCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Shipment:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Shipment(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment(
db: Session, shipment_id: int, payload: ShipmentUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Shipment:
obj = get_shipment(db, shipment_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> None:
obj = get_shipment(db, shipment_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def create_shipment_from_quote(
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.
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(
Quote.id == quote_id,
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
.first()
)
if not quote:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
if quote.status != "aceptada":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La cotización debe estar aceptada para liberarse a Operaciones",
)
sr = None
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=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=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,
origin=sr.origin if sr else None,
destination=sr.destination if sr else None,
destination_agent_id=sr.destination_agent_id if sr else None,
status="abierta",
owner_user_id=quote.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
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
# ----- Documentos del embarque -----
def get_shipment_documents(
db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None
) -> list[ShipmentDocument]:
query = db.query(ShipmentDocument).filter(
ShipmentDocument.tenant_id == tenant_id,
ShipmentDocument.company_id == company_id,
ShipmentDocument.deleted_at.is_(None),
)
if shipment_id is not None:
query = query.filter(ShipmentDocument.shipment_id == shipment_id)
return query.order_by(ShipmentDocument.id.asc()).all()
def _get_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> ShipmentDocument:
obj = (
db.query(ShipmentDocument)
.filter(
ShipmentDocument.id == doc_id,
ShipmentDocument.tenant_id == tenant_id,
ShipmentDocument.company_id == company_id,
ShipmentDocument.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado")
return obj
def create_shipment_document(
db: Session, payload: ShipmentDocumentCreate, tenant_id: int, company_id: int
) -> ShipmentDocument:
get_shipment(db, payload.shipment_id, tenant_id, company_id) # valida scope
obj = ShipmentDocument(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment_document(
db: Session, doc_id: int, payload: ShipmentDocumentUpdate, tenant_id: int, company_id: int
) -> ShipmentDocument:
obj = _get_document(db, doc_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> None:
obj = _get_document(db, doc_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
# ----- Bitácora / hitos del embarque -----
def get_shipment_events(db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None) -> list[ShipmentEvent]:
query = db.query(ShipmentEvent).filter(
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
if shipment_id is not None:
query = query.filter(ShipmentEvent.shipment_id == shipment_id)
return query.order_by(ShipmentEvent.position.asc(), ShipmentEvent.id.asc()).all()
def _get_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = (
db.query(ShipmentEvent)
.filter(
ShipmentEvent.id == event_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hito no encontrado")
return obj
def create_shipment_event(db: Session, payload: ShipmentEventCreate, tenant_id: int, company_id: int) -> ShipmentEvent:
get_shipment(db, payload.shipment_id, tenant_id, company_id)
obj = ShipmentEvent(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment_event(db: Session, event_id: int, payload: ShipmentEventUpdate, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = _get_event(db, event_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def complete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = _get_event(db, event_id, tenant_id, company_id)
# Un punto de decisión no se "completa" a mano: se resuelve con decide_shipment_event
if obj.kind == "decision":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Este hito es un punto de decisión: resuélvelo como autorizado o rechazado",
)
obj.status = "completado"
obj.actual_date = datetime.now(timezone.utc)
db.commit()
db.refresh(obj)
return obj
def decide_shipment_event(
db: Session, event_id: int, payload: ShipmentEventDecisionInput, tenant_id: int, company_id: int
) -> ShipmentEvent:
"""Resuelve un punto de decisión del flujo (Cut Off / despacho autorizado).
- autorizado → la decisión queda completada y el flujo continúa.
- rechazado → la decisión queda 'rechazada' y se genera automáticamente un hito
de corrección (rehacer trámite) que apunta a esta decisión, implementando el
ciclo de corrección de los diagramas 2 (R-E-14) y 3 (R-I-07).
"""
obj = _get_event(db, event_id, tenant_id, company_id)
if obj.kind != "decision":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Solo los puntos de decisión aceptan un resultado (autorizado/rechazado)",
)
obj.outcome = payload.outcome
obj.actual_date = datetime.now(timezone.utc)
if payload.notes:
obj.notes = payload.notes
if payload.outcome == "autorizado":
obj.status = "completado"
db.commit()
db.refresh(obj)
return obj
# Rechazado: se abre el ciclo de corrección
obj.status = "rechazado"
correction = ShipmentEvent(
shipment_id=obj.shipment_id,
event_type=f"{obj.event_type or 'tramite'}_correccion",
title=f"Corrección: rehacer trámite — {obj.title}",
kind="hito",
status="en_correccion",
parent_event_id=obj.id,
attempt=(obj.attempt or 1) + 1,
# Se inserta justo después de la decisión rechazada para conservar el orden del flujo
position=obj.position,
tenant_id=tenant_id,
company_id=company_id,
)
# Empuja una posición los hitos posteriores para dejar hueco a la corrección
db.query(ShipmentEvent).filter(
ShipmentEvent.shipment_id == obj.shipment_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
ShipmentEvent.position > obj.position,
).update({ShipmentEvent.position: ShipmentEvent.position + 1})
correction.position = obj.position + 1
db.add(correction)
db.commit()
db.refresh(obj)
return obj
def delete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> None:
obj = _get_event(db, event_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def reschedule_departure(
db: Session, shipment_id: int, payload: ShipmentRescheduleInput, tenant_id: int, company_id: int,
user_id: str | None = None,
) -> Shipment:
"""Reprograma la salida cuando no se alcanza el Cut Off (R-E-06).
Conserva la salida anterior en ``previous_etd`` y deja constancia en la bitácora.
"""
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
if payload.etd is not None:
shipment.previous_etd = shipment.etd
shipment.etd = payload.etd
if payload.cutoff_date is not None:
shipment.cutoff_date = payload.cutoff_date
shipment.updated_by = user_id
last_pos = (
db.query(func.max(ShipmentEvent.position))
.filter(
ShipmentEvent.shipment_id == shipment_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
.scalar()
)
detail = payload.reason or "Reprogramación de salida por Cut Off no alcanzado"
db.add(
ShipmentEvent(
shipment_id=shipment_id,
event_type="reprogramacion",
title="Reprogramación de salida (nuevo Cut Off / ETD)",
kind="hito",
status="completado",
actual_date=datetime.now(timezone.utc),
position=(last_pos or 0) + 1,
notes=detail,
tenant_id=tenant_id,
company_id=company_id,
)
)
db.commit()
db.refresh(shipment)
return shipment
def close_shipment(
db: Session, shipment_id: int, payload: ShipmentCloseInput, tenant_id: int, company_id: int,
user_id: str | None = None,
) -> Shipment:
"""Cierre operativo del embarque con costos finales (R-E-22).
Marca el embarque como 'cerrada' y registra los costos reales; el cierre es el
disparador válido de la facturación (R-F-01). No permite cerrar si quedan puntos
de decisión sin resolver.
"""
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
if shipment.status == "cancelada":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="El embarque está cancelado"
)
pending_decision = (
db.query(ShipmentEvent.id)
.filter(
ShipmentEvent.shipment_id == shipment_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
ShipmentEvent.kind == "decision",
ShipmentEvent.status.in_(["pendiente", "rechazado", "en_correccion"]),
)
.first()
)
if pending_decision:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No se puede cerrar: hay puntos de decisión pendientes o en corrección",
)
shipment.actual_cost_total = payload.actual_cost_total
shipment.cost_currency = payload.cost_currency
shipment.status = "cerrada"
shipment.closed_at = datetime.now(timezone.utc)
shipment.closed_by = user_id
shipment.updated_by = user_id
db.commit()
db.refresh(shipment)
return shipment
def seed_default_milestones(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> list[ShipmentEvent]:
"""Crea los hitos por defecto del embarque según su tipo de operación (import/export)."""
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
existing = get_shipment_events(db, tenant_id, company_id, shipment_id)
if existing:
return existing
milestones = _DEFAULT_MILESTONES.get(shipment.operation_type or "", [])
if not milestones:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Define el tipo de operación (importación/exportación) para generar los hitos",
)
created = []
for position, (event_type, title, kind) in enumerate(milestones):
ev = 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.add(ev)
created.append(ev)
db.commit()
for ev in created:
db.refresh(ev)
return created

View File

@@ -6,6 +6,8 @@ from fastapi import APIRouter
from .modules.core.router import router as core_router
from .modules.crm.router import router as crm_router
from .modules.ops.router import router as ops_router
from .modules.fin.router import router as fin_router
from .modules.example.routes import router as example_router
@@ -13,6 +15,8 @@ router = APIRouter()
router.include_router(core_router)
router.include_router(crm_router, prefix="/crm", tags=["crm"])
router.include_router(ops_router, prefix="/ops", tags=["ops"])
router.include_router(fin_router, prefix="/fin", tags=["fin"])
router.include_router(example_router, prefix="/example", tags=["example"])

View File

@@ -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/"

67
backend/core/hub_token.py Normal file
View File

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

View File

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

View File

@@ -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:<mensaje>". 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):
"""

View File

@@ -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:
@@ -653,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

View File

@@ -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)

View File

@@ -22,7 +22,21 @@ from api.v1.modules.crm.documents.models import Document
from api.v1.modules.crm.leads.models import Lead
from api.v1.modules.crm.opportunities.models import Opportunity
from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument, ShipmentEvent
from api.v1.modules.ops.shipments import service as shipments_service
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentEventDecisionInput
from api.v1.modules.fin.invoices.models import Invoice
from api.v1.modules.fin.invoices import service as invoices_service
from api.v1.modules.fin.invoices.dto import PaymentCreate
from api.v1.modules.core.permissions.models import CompanyRole, Permission, RolePermission
from api.v1.modules.core.permissions.service import PermissionService
# Efecto secundario: poblar el PermissionRegistry con los permisos de cada dominio
import api.v1.modules.crm.permissions # noqa: F401
import api.v1.modules.ops.permissions # noqa: F401
import api.v1.modules.fin.permissions # noqa: F401
from core.database import CoreSessionLocal
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
@@ -254,6 +268,188 @@ def seed_suppliers_and_related(db) -> None:
print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor")
def seed_commercial_and_ops(db) -> None:
"""Demo del flujo comercial: Solicitud → Cotización (aceptada) → Embarque liberado."""
if db.query(ServiceRequest).filter(
ServiceRequest.tenant_id == TENANT_ID, ServiceRequest.company_id == COMPANY_ID,
ServiceRequest.deleted_at.is_(None),
).first():
print("• Ya existe flujo comercial; se omite")
return
account = (
db.query(Account)
.filter(Account.tenant_id == TENANT_ID, Account.company_id == COMPANY_ID, Account.deleted_at.is_(None))
.order_by(Account.id.asc())
.first()
)
account_id = account.id if account else None
# 1. Solicitud de servicio (RFQ)
sr = ServiceRequest(
reference="SOL-0001", account_id=account_id, operation_type="exportacion",
transport_mode="maritimo", service_type="puerta_puerta", incoterm="FOB",
origin="Manzanillo, MX", destination="Long Beach, US", cargo_type="Carga general",
load_type="FCL", container_equipment="1x40'HC", status="cotizada",
tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(sr)
db.flush()
# 2. Cotización aceptada con conceptos
quote = Quote(
reference="COT-0001", service_request_id=sr.id, account_id=account_id, currency="USD",
status="aceptada", tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(quote)
db.flush()
items = [
("flete_internacional", 1, 1800, 2200),
("transporte_terrestre", 1, 350, 500),
("despacho_aduanal", 1, 200, 320),
]
total_cost = total_sale = 0
for concept, qty, cost, sale in items:
db.add(QuoteItem(quote_id=quote.id, concept=concept, quantity=qty, unit_cost=cost,
unit_sale=sale, currency="USD", tenant_id=TENANT_ID, company_id=COMPANY_ID))
total_cost += qty * cost
total_sale += qty * sale
quote.total_cost = total_cost
quote.total_sale = total_sale
sr.status = "liberada"
# 3. Embarque liberado a Operaciones
shipment = Shipment(
reference="EMB-0001", quote_id=quote.id, service_request_id=sr.id, account_id=account_id,
operation_type=sr.operation_type, transport_mode=sr.transport_mode, service_type=sr.service_type,
incoterm=sr.incoterm, origin=sr.origin, destination=sr.destination,
status="booking", booking_number="BKG-778812", tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(shipment)
db.flush()
db.add(ShipmentDocument(shipment_id=shipment.id, doc_kind="master", doc_type="MBL",
number="MBLU12345678", tenant_id=TENANT_ID, company_id=COMPANY_ID))
db.commit()
print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL")
def seed_invoicing_and_events(db) -> None:
"""Factura desde el embarque (con pago parcial) + hitos de la bitácora."""
from decimal import Decimal
if db.query(Invoice).filter(
Invoice.tenant_id == TENANT_ID, Invoice.company_id == COMPANY_ID, Invoice.deleted_at.is_(None)
).first():
print("• Ya existe facturación; se omite")
return
shipment = (
db.query(Shipment)
.filter(Shipment.tenant_id == TENANT_ID, Shipment.company_id == COMPANY_ID, Shipment.deleted_at.is_(None))
.order_by(Shipment.id.asc())
.first()
)
if not shipment:
print("• Sin embarque; se omite facturación")
return
# Bitácora de hitos (según tipo de operación, incluye puntos de decisión)
events = shipments_service.seed_default_milestones(db, shipment.id, TENANT_ID, COMPANY_ID)
# Resolver los puntos de decisión como autorizados y completar los hitos simples,
# para poder cerrar operativamente el embarque.
for ev in events:
if ev.kind == "decision":
shipments_service.decide_shipment_event(
db, ev.id, ShipmentEventDecisionInput(outcome="autorizado"), TENANT_ID, COMPANY_ID
)
else:
shipments_service.complete_shipment_event(db, ev.id, TENANT_ID, COMPANY_ID)
# Cierre operativo con costos finales (dispara la facturación — R-F-01/R-E-22)
shipments_service.close_shipment(
db, shipment.id,
ShipmentCloseInput(actual_cost_total=Decimal("2350"), cost_currency="USD", notes="Cierre demo"),
TENANT_ID, COMPANY_ID,
)
# Factura generada desde el embarque cerrado (toma conceptos de la cotización)
invoice = invoices_service.generate_from_shipment(db, shipment.id, TENANT_ID, COMPANY_ID)
invoices_service.emit_invoice(db, invoice.id, TENANT_ID, COMPANY_ID)
invoices_service.create_payment(
db, PaymentCreate(invoice_id=invoice.id, amount=Decimal("1000"), method="transferencia", reference="SPEI-001"),
TENANT_ID, COMPANY_ID,
)
print("✓ Embarque cerrado, factura emitida con pago parcial + bitácora con decisiones resueltas")
# Carriles del proceso (R-T-07): a qué módulos/acciones puede acceder cada rol.
# clave = (code, nombre); valor = función que decide si un permiso pertenece al rol.
def _carril_roles() -> dict:
# cada función recibe el CÓDIGO del permiso (str) y decide si pertenece al carril
return {
("ventas", "Ventas"): lambda c: c.startswith("crm.") or c in {"ops.access", "ops.shipment.view"},
("operaciones", "Operaciones"): lambda c: c.startswith("ops.")
or c in {"crm.access", "crm.account.view", "crm.quote.view", "crm.service_request.view", "fin.access", "fin.invoice.view"},
("facturacion", "Facturación"): lambda c: c.startswith("fin.")
or c in {"ops.access", "ops.shipment.view", "crm.access", "crm.account.view"},
("consulta", "Consulta"): lambda c: c.endswith(".access") or c.endswith(".view"),
}
def ensure_company(db) -> None:
"""Garantiza la empresa dev (a76.company id=1), requerida por la FK company_id de
los roles/permisos (core.company_roles → a76.company). La plantilla no la crea."""
from sqlalchemy import text
exists = db.execute(text("SELECT 1 FROM a76.company WHERE id = :id"), {"id": COMPANY_ID}).first()
if exists:
print(f"• Empresa id={COMPANY_ID} ya existe (a76.company)")
return
db.execute(
text("INSERT INTO a76.company (id, tenant_id) VALUES (:id, :tid)"),
{"id": COMPANY_ID, "tid": TENANT_ID},
)
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
print(f"✓ Empresa demo id={COMPANY_ID} creada (a76.company)")
def seed_carril_roles(db) -> None:
"""Crea los roles por carril del proceso con sus permisos (R-T-07). Idempotente."""
# Poblar el catálogo de permisos desde el registry de código
PermissionService(db).sync_permissions()
all_perms = db.query(Permission).filter(Permission.is_active == True).all() # noqa: E712
created = 0
for (code, name), belongs in _carril_roles().items():
role = (
db.query(CompanyRole)
.filter(CompanyRole.company_id == COMPANY_ID, CompanyRole.tenant_id == TENANT_ID, CompanyRole.code == code)
.first()
)
if not role:
role = CompanyRole(
company_id=COMPANY_ID, tenant_id=TENANT_ID, name=name, code=code,
description=f"Rol de carril: {name}", is_active=True,
)
db.add(role)
db.flush()
created += 1
existing_perm_ids = {
pid for (pid,) in db.query(RolePermission.permission_id).filter(RolePermission.company_role_id == role.id)
}
for perm in all_perms:
if belongs(perm.code) and perm.id not in existing_perm_ids:
db.add(RolePermission(
company_role_id=role.id, permission_id=perm.id,
tenant_id=TENANT_ID, company_id=COMPANY_ID,
))
db.commit()
print(f"✓ Roles por carril sembrados (nuevos: {created}) — Ventas, Operaciones, Facturación, Consulta")
def main() -> None:
db = CoreSessionLocal()
try:
@@ -261,6 +457,10 @@ def main() -> None:
pipeline, stages = ensure_pipeline(db)
seed_sample_data(db, pipeline, stages)
seed_suppliers_and_related(db)
seed_commercial_and_ops(db)
seed_invoicing_and_events(db)
ensure_company(db)
seed_carril_roles(db)
print("\nSeed CRM completado.")
finally:
db.close()

View File

@@ -28,14 +28,20 @@ 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
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
import api.v1.modules.crm.quotes.models # noqa: E402,F401
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
import api.v1.modules.ops.shipments.models # noqa: E402,F401
import api.v1.modules.fin.invoices.models # noqa: E402,F401
_SCHEMA_MAP = {"crm": None, "core": None}
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None}
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.

View File

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

View File

@@ -0,0 +1,169 @@
"""Pruebas de las reglas de negocio agregadas para cumplir el PDF de agente de carga.
Cubren: puntos de decisión y ciclo de corrección de la bitácora (R-E-13/14, R-I-06/07),
cierre operativo y gate de facturación (R-E-22 / R-F-01), envío y revisión de factura
(R-F-05/06) y continuidad comercial (R-C-02/04/12) + catálogo de Incoterms (R-T-10).
"""
from decimal import Decimal
import pytest
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.opportunities import service as opps_service
from api.v1.modules.crm.opportunities.dto import OpportunityCreate
from api.v1.modules.crm.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import (
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
)
from api.v1.modules.fin.invoices import service as inv_service
from api.v1.modules.fin.invoices.dto import InvoiceClientReviewInput, InvoiceCreate, InvoiceItemCreate
from api.v1.modules.ops.shipments import service as ops_service
from api.v1.modules.ops.shipments.dto import (
ShipmentCloseInput,
ShipmentCreate,
ShipmentEventDecisionInput,
ShipmentRescheduleInput,
)
T, C = 1, 1
def _shipment(db, op_type="exportacion"):
return ops_service.create_shipment(db, ShipmentCreate(reference="EMB-T", operation_type=op_type), T, C)
# ---------- Bitácora: decisiones y ciclo de corrección ----------
def test_seed_milestones_include_decision_points(db):
sh = _shipment(db, "exportacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
decisions = [e for e in events if e.kind == "decision"]
assert any(e.event_type == "decision_cutoff" for e in decisions)
assert any(e.event_type == "decision_despacho_exportacion" for e in decisions)
def test_decision_autorizado_completa(db):
sh = _shipment(db, "importacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
decision = next(e for e in events if e.kind == "decision")
updated = ops_service.decide_shipment_event(db, decision.id, ShipmentEventDecisionInput(outcome="autorizado"), T, C)
assert updated.status == "completado" and updated.outcome == "autorizado"
def test_decision_rechazado_abre_correccion(db):
sh = _shipment(db, "importacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
before = len(ops_service.get_shipment_events(db, T, C, sh.id))
decision = next(e for e in events if e.kind == "decision")
ops_service.decide_shipment_event(db, decision.id, ShipmentEventDecisionInput(outcome="rechazado", notes="Docs incompletos"), T, C)
after = ops_service.get_shipment_events(db, T, C, sh.id)
assert len(after) == before + 1 # se creó el hito de corrección
correction = next(e for e in after if e.parent_event_id == decision.id)
assert correction.status == "en_correccion" and correction.attempt == 2
def test_complete_on_decision_falla(db):
sh = _shipment(db, "importacion")
events = ops_service.seed_default_milestones(db, sh.id, T, C)
decision = next(e for e in events if e.kind == "decision")
with pytest.raises(HTTPException) as exc:
ops_service.complete_shipment_event(db, decision.id, T, C)
assert exc.value.status_code == 422
# ---------- Cierre operativo y gate de facturación ----------
def test_close_blocked_with_pending_decision(db):
sh = _shipment(db, "importacion")
ops_service.seed_default_milestones(db, sh.id, T, C) # deja decisiones pendientes
with pytest.raises(HTTPException) as exc:
ops_service.close_shipment(db, sh.id, ShipmentCloseInput(actual_cost_total=Decimal("100")), T, C)
assert exc.value.status_code == 409
def test_generate_invoice_requires_closed_shipment(db):
sh = _shipment(db, "exportacion") # abierta, sin cerrar
with pytest.raises(HTTPException) as exc:
inv_service.generate_from_shipment(db, sh.id, T, C)
assert exc.value.status_code == 409
# Tras cerrar (sin hitos → sin decisiones pendientes) sí factura
ops_service.close_shipment(db, sh.id, ShipmentCloseInput(actual_cost_total=Decimal("500"), cost_currency="MXN"), T, C)
inv = inv_service.generate_from_shipment(db, sh.id, T, C)
assert inv.shipment_id == sh.id
def test_reschedule_keeps_previous_etd(db):
from datetime import date
sh = ops_service.create_shipment(db, ShipmentCreate(reference="EMB-R", operation_type="exportacion", etd=date(2026, 1, 10)), T, C)
ops_service.reschedule_departure(db, sh.id, ShipmentRescheduleInput(etd=date(2026, 1, 20), reason="Cut Off perdido"), T, C)
sh = ops_service.get_shipment(db, sh.id, T, C)
assert str(sh.previous_etd) == "2026-01-10" and str(sh.etd) == "2026-01-20"
# ---------- Envío y revisión de factura ----------
def test_send_invoice_generates_pdf_and_reviews(db, monkeypatch):
stored = {}
monkeypatch.setattr("core.storage_s3.put_object_bytes", lambda key, body, content_type="": stored.update({"key": key, "len": len(body)}))
acc = accounts_service.create_account(db, AccountCreate(name="Cliente PDF"), T, C)
inv = inv_service.create_invoice(db, InvoiceCreate(reference="F-PDF", account_id=acc.id, tax_rate=Decimal("16")), T, C)
inv_service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
inv_service.emit_invoice(db, inv.id, T, C)
inv = inv_service.send_invoice(db, inv.id, T, C)
assert inv.status == "enviada" and inv.pdf_file_key and stored["len"] > 0
# Revisión del cliente: aprobada
inv = inv_service.mark_client_review(db, inv.id, T, C)
assert inv.status == "en_revision_cliente"
inv = inv_service.client_review_decision(db, inv.id, InvoiceClientReviewInput(approved=True, notes="OK"), T, C)
assert inv.status == "enviada" and inv.client_approved is True and inv.client_reviewed_at is not None
# ---------- Continuidad comercial ----------
def test_register_contact_sets_stage(db):
acc = accounts_service.create_account(db, AccountCreate(name="Prospecto"), T, C)
sr = sr_service.create_service_request(db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion"), T, C)
sr = sr_service.register_contact(db, sr.id, ServiceRequestContactInput(notes="Primer contacto"), T, C)
assert sr.status == "contacto" and sr.first_contact_at is not None
def test_service_request_from_opportunity_links_back(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Op"), T, C)
opp = opps_service.create_opportunity(db, OpportunityCreate(name="Oportunidad X", account_id=acc.id), T, C)
sr = sr_service.create_from_opportunity(
db, opp.id, ServiceRequestFromOpportunityInput(operation_type="importacion"), T, C
)
assert sr.opportunity_id == opp.id and sr.account_id == acc.id
def test_clone_quote_reopens_request(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Q"), T, C)
sr = sr_service.create_service_request(db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion"), T, C)
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-1", account_id=acc.id, service_request_id=sr.id, currency="USD"), T, C)
quotes_service.create_quote_item(db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=1, unit_cost=100, unit_sale=200), T, C)
quotes_service.reject_quote(db, q.id, T, C)
clone = quotes_service.clone_quote(db, q.id, T, C)
assert clone.id != q.id and clone.status == "borrador"
items = quotes_service.get_quote_items(db, clone.id, T, C)
assert len(items) == 1
sr = sr_service.get_service_request(db, sr.id, T, C)
assert sr.status == "en_analisis" # reabierta para re-cotizar
def test_incoterm_catalog_validation(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente Inc"), T, C)
with pytest.raises(HTTPException) as exc:
sr_service.create_service_request(
db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", incoterm="XXX"), T, C
)
assert exc.value.status_code == 422
ok = sr_service.create_service_request(
db, ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", incoterm="FOB"), T, C
)
assert ok.incoterm == "FOB"

View File

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

View File

@@ -0,0 +1,57 @@
from decimal import Decimal
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.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
from api.v1.modules.fin.invoices import service
from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, PaymentCreate
from api.v1.modules.ops.shipments import service as shipments_service
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentCreate
T, C = 1, 1
def test_invoice_totals_with_tax(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-001", currency="MXN", tax_rate=Decimal("16")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 240.0 # 16% de 1500
assert float(inv.total) == 1740.0
assert float(inv.balance) == 1740.0
def test_payment_marks_paid(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-002", tax_rate=Decimal("0")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000), T, C)
service.emit_invoice(db, inv.id, T, C)
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("400"), method="transferencia"), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.paid_amount) == 400.0 and float(inv.balance) == 600.0
assert inv.status == "emitida"
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("600")), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.balance) == 0.0 and inv.status == "pagada" and inv.paid_at is not None
def test_generate_from_shipment_copies_quote_items(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
quote = quotes_service.create_quote(db, QuoteCreate(reference="COT-9", account_id=acc.id, currency="USD"), T, C)
quotes_service.create_quote_item(db, QuoteItemCreate(quote_id=quote.id, concept="flete_internacional", quantity=1, unit_cost=1000, unit_sale=1500), T, C)
quotes_service.accept_quote(db, quote.id, T, C)
shipment = shipments_service.create_shipment_from_quote(db, quote.id, T, C)
# El embarque debe cerrarse operativamente antes de facturar (R-F-01)
shipments_service.close_shipment(
db, shipment.id, ShipmentCloseInput(actual_cost_total=Decimal("1000"), cost_currency="USD"), T, C
)
inv = service.generate_from_shipment(db, shipment.id, T, C)
assert inv.shipment_id == shipment.id
assert inv.account_id == acc.id
assert inv.currency == "USD"
assert float(inv.ops_cost_total) == 1000.0 # costos de operación arrastrados (R-F-02)
items = service.get_items(db, inv.id, T, C)
assert len(items) == 1 and float(items[0].unit_amount) == 1500.0
assert float(inv.subtotal) == 1500.0

View File

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

View File

@@ -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)

View File

@@ -0,0 +1,154 @@
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 RateRequestCreate, ServiceRequestCreate
T, C = 1, 1
def test_quote_totals_recompute_on_items(db):
q = service.create_quote(db, QuoteCreate(reference="COT-001", currency="USD"), T, C)
service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=2, unit_cost=100, unit_sale=150), T, C
)
service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="despacho_aduanal", quantity=1, unit_cost=50, unit_sale=90), T, C
)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_cost) == 250.0 # 2*100 + 1*50
assert float(q.total_sale) == 390.0 # 2*150 + 1*90
def test_quote_totals_update_and_delete_item(db):
q = service.create_quote(db, QuoteCreate(reference="COT-002"), T, C)
item = service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="otros", quantity=1, unit_cost=100, unit_sale=200), T, C
)
service.update_quote_item(db, item.id, QuoteItemUpdate(unit_sale=Decimal("300")), T, C)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_sale) == 300.0
service.delete_quote_item(db, item.id, T, C)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_sale) == 0.0
def test_accept_quote_updates_service_request(db):
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
q = service.create_quote(db, QuoteCreate(reference="COT-003", service_request_id=sr.id), T, C)
service.send_quote(db, q.id, T, C)
accepted = service.accept_quote(db, q.id, T, C)
assert accepted.status == "aceptada"
assert accepted.accepted_at is not None
# 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_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_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)
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()

View File

@@ -0,0 +1,156 @@
import pytest
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,
)
T, C = 1, 1
def test_create_service_request(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
sr = service.create_service_request(
db,
ServiceRequestCreate(
account_id=acc.id, operation_type="exportacion", transport_mode="maritimo",
service_type="puerta_puerta", origin="Manzanillo", destination="Long Beach",
incoterm="FOB", load_type="FCL",
),
T, C, user_id="dev",
)
assert sr.id is not None
assert sr.status == "nueva"
assert sr.created_by == "dev"
def test_service_request_rejects_unknown_account(db):
with pytest.raises(HTTPException) as exc:
service.create_service_request(db, ServiceRequestCreate(account_id=999, operation_type="importacion"), T, C)
assert exc.value.status_code == 422
def test_filter_by_operation_and_status(db):
service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
service.create_service_request(db, ServiceRequestCreate(operation_type="importacion"), T, C)
exp = service.get_service_requests(db, T, C, operation_type="exportacion")
assert len(exp) == 1 and exp[0].operation_type == "exportacion"
def test_rate_request_requires_service_request(db):
with pytest.raises(HTTPException) as exc:
service.create_rate_request(
db, RateRequestCreate(service_request_id=999, concept="flete_internacional"), T, C
)
assert exc.value.status_code == 404
def test_rate_request_ok_and_listed(db):
sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
service.create_rate_request(
db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional", rate_amount=1200, currency="USD"),
T, C,
)
rates = service.get_rate_requests(db, T, C, service_request_id=sr.id)
assert len(rates) == 1 and rates[0].concept == "flete_internacional"
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

View File

@@ -0,0 +1,34 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.ops.shipments import service
from api.v1.modules.ops.shipments.dto import ShipmentCreate
T, C = 1, 1
def test_seed_import_milestones(db):
s = service.create_shipment(db, ShipmentCreate(reference="IMP-1", operation_type="importacion"), T, C)
events = service.seed_default_milestones(db, s.id, T, C)
titles = [e.event_type for e in events]
assert "aviso_llegada" in titles
assert "despacho_importacion" in titles
assert "entrega" in titles
# idempotente: no duplica
again = service.seed_default_milestones(db, s.id, T, C)
assert len(again) == len(events)
def test_seed_export_milestones_and_complete(db):
s = service.create_shipment(db, ShipmentCreate(reference="EXP-1", operation_type="exportacion"), T, C)
events = service.seed_default_milestones(db, s.id, T, C)
assert any(e.event_type == "embarque" for e in events)
done = service.complete_shipment_event(db, events[0].id, T, C)
assert done.status == "completado" and done.actual_date is not None
def test_seed_requires_operation_type(db):
s = service.create_shipment(db, ShipmentCreate(reference="X-1"), T, C) # sin operation_type
with pytest.raises(HTTPException) as exc:
service.seed_default_milestones(db, s.id, T, C)
assert exc.value.status_code == 422

View File

@@ -0,0 +1,104 @@
import pytest
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.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate
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.ops.shipments import service
from api.v1.modules.ops.shipments.dto import ShipmentCreate, ShipmentDocumentCreate
T, C = 1, 1
def test_release_requires_accepted_quote(db):
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-A"), T, C)
with pytest.raises(HTTPException) as exc:
service.create_shipment_from_quote(db, q.id, T, C)
assert exc.value.status_code == 422 # aún no aceptada
def test_release_from_accepted_quote_copies_data(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
sr = sr_service.create_service_request(
db,
ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", transport_mode="maritimo",
origin="Veracruz", destination="Rotterdam"),
T, C,
)
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-B", service_request_id=sr.id, account_id=acc.id), T, C)
quotes_service.accept_quote(db, q.id, T, C)
shipment = service.create_shipment_from_quote(db, q.id, T, C, user_id="dev")
assert shipment.quote_id == q.id
assert shipment.account_id == acc.id
assert shipment.operation_type == "exportacion"
assert shipment.transport_mode == "maritimo"
assert shipment.origin == "Veracruz"
assert shipment.status == "abierta"
# la solicitud queda liberada
sr = sr_service.get_service_request(db, sr.id, T, C)
assert sr.status == "liberada"
def test_shipment_crud_and_documents(db):
shipment = service.create_shipment(db, ShipmentCreate(reference="EMB-001", status="abierta", origin="MX"), T, C)
assert shipment.id is not None
doc = service.create_shipment_document(
db, ShipmentDocumentCreate(shipment_id=shipment.id, doc_kind="master", doc_type="MBL", number="MBL123"), T, C
)
assert doc.doc_type == "MBL"
docs = service.get_shipment_documents(db, T, C, shipment_id=shipment.id)
assert len(docs) == 1
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

84
deploy/README.md Normal file
View File

@@ -0,0 +1,84 @@
# Despliegue — testing.crm.aduanasoft.com (entorno de pruebas)
Guía para publicar el CRM Agente de Carga en el servidor de pruebas. El estándar
Aduanasoft es desplegar vía **Jenkins (CI/CD)**; este runbook manual es para el
levantamiento inicial o cuando el pipeline aún no está conectado.
> **Servidor:** `deploy@216.250.125.140:3232` · **DNS:** `testing.crm.aduanasoft.com` → `216.250.125.140`
## 0. Reglas de seguridad (no negociables)
- `ENVIRONMENT=production` y `DEV_LOCAL_AUTH=false`. En `development` el RBAC
auto-bootstrapea **super_admin** a cualquiera y se activa el login local — inaceptable
en un dominio público.
- Los secretos (DB, S3, `SECRET_KEY`, Keycloak) los captura **el operador en el servidor**,
nunca se versionan ni se comparten por chat.
- Las **migraciones** las ejecuta el operador/CI, no de forma automática. No correr
migraciones contra producción.
## 1. Acceso por llave (una vez)
Autoriza la llave pública del operador en el servidor (desde una máquina que ya entre):
```bash
ssh -p 3232 deploy@216.250.125.140 \
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '<TU_LLAVE_PUBLICA>' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
```
## 2. Código y entorno
```bash
ssh -p 3232 deploy@216.250.125.140
git clone https://git.aduanasoft.com/ADUANASOFT/CRM_AGENTES_CARGA.git
cd CRM_AGENTES_CARGA
git checkout feature/crm-cumplimiento-pdf # o la rama/tag liberado
cp deploy/env.testing.example .env
$EDITOR .env # rellenar TODOS los CHANGE_ME
```
## 3. Build de producción (importante)
El `docker-compose.yml` del repo está orientado a desarrollo (vite dev + `--reload`).
Para un sitio público hay que servir la **build** de producción:
- **Frontend:** `npm ci && npm run build` (adapter-node) y arrancar con `node build`
escuchando en `5173`, con `ORIGIN=https://testing.crm.aduanasoft.com`.
- **Backend:** `uvicorn main:app --host 0.0.0.0 --port 8000` **sin** `--reload`.
- Publica los puertos SOLO en loopback (override):
```yaml
# docker-compose.testing.yml (ejemplo de override)
services:
backend:
ports: ["127.0.0.1:8000:8000"]
command: ["uvicorn","main:app","--host","0.0.0.0","--port","8000"]
frontend:
ports: ["127.0.0.1:5173:5173"]
command: ["node","build"]
```
```bash
docker compose -f docker-compose.yml -f docker-compose.testing.yml up -d --build
```
## 4. Migraciones y datos base
```bash
docker compose exec backend alembic upgrade head
# Catálogo de permisos + roles por carril (Ventas/Operaciones/Facturación/Consulta):
docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()"
```
> En producción NO se usa el auto-bootstrap de dev: asigna los roles a los usuarios
> reales desde el módulo de Roles y permisos.
## 5. Nginx + TLS
```bash
sudo cp deploy/nginx/testing.crm.aduanasoft.com.conf /etc/nginx/sites-available/
sudo ln -s /etc/nginx/sites-available/testing.crm.aduanasoft.com.conf /etc/nginx/sites-enabled/
sudo mkdir -p /var/www/certbot
sudo certbot certonly --webroot -w /var/www/certbot -d testing.crm.aduanasoft.com
sudo nginx -t && sudo systemctl reload nginx
```
## 6. Smoke test
```bash
curl -fsS https://testing.crm.aduanasoft.com/api/health && echo OK
# Abre la app y valida login (Keycloak), listar clientes, y un flujo end-to-end.
```
## Notas
- App y API van en el **mismo origen** (`/` y `/api/`) para no requerir CORS entre hosts.
- MinIO: si el navegador debe abrir URLs prefirmadas, `S3_ENDPOINT_URL` debe resolver a un
host accesible públicamente (o publicar MinIO detrás de nginx en otro subdominio). Revisar
según la política de red del entorno de pruebas.

View File

@@ -0,0 +1,162 @@
# Runbook — Despliegue a PRODUCCIÓN · CRM Agente de Carga
> **Quién ejecuta:** un operador con acceso al servidor de producción y a la base de
> datos de prod. **Este runbook no lo ejecuta ningún agente automático.**
> **Prerrequisito de proceso:** el PR de `feature/crm-workspace-altas-org-usuario`
> debe estar **revisado y mergeado** a la rama que corresponda antes de desplegar.
>
> **Antes de empezar: respaldo.** Toma un backup de la base `crm_core` de prod
> (`pg_dump`) y del `.env` actual. Sin respaldo verificado, no continúes.
Este cambio es grande (auth/RBAC): login 100% vía Workspace/Hub, **sesión local
(patrón SIWEB)**, gestión de compañías/usuarios/roles y provisión vía Hub. Léelo
completo antes de tocar prod.
---
## 0. Alcance del cambio
- **Auth:** el login deja de hablar directo con Keycloak; todo pasa por el Hub
(App Launcher → `/auth/sso?relay=<uuid>``POST {HUB_URL}/api/v1/auth/sso-exchange`).
- **Sesión local:** cookie de sesión propia (HS256) + token KC guardado en **valkey**
por `crm_sid`. Requiere valkey arriba. Se controla con `SESSION_STORE_ENABLED`.
- **RBAC/Compañías:** compañías en `a76.company` por tenant; roles por carril
(Ventas, Operaciones, Facturación, Consulta); permisos por módulo.
**Migraciones de esquema:** este set **no** agrega tablas nuevas (usa `core.*`,
`a76.company` y valkey). Aun así, **verifica migraciones pendientes** en prod antes
de desplegar (paso 5). No corras migraciones a ciegas.
---
## 1. Prerrequisitos de infraestructura
- [ ] DNS de prod (p. ej. `crm.aduanasoft.com`) apuntando al server de prod.
- [ ] Docker + Docker Compose en el server.
- [ ] Servicios del stack: `backend`, `frontend`, `postgres`, `valkey`, `minio`,
`celery_worker`, `celery_beat`.
- [ ] **valkey** operativo (lo usan la sesión local y `hub_token`).
- [ ] nginx + TLS (certbot) para servir app + API en el **mismo origen**.
- [ ] Acceso al **Hub de producción** (no el de testing) y al client/realm correctos.
---
## 2. Variables de entorno (`.env` en el server de prod)
Basado en `deploy/env.testing.example`, pero con **hosts, dominio y secretos de
producción**. Nunca subas el `.env` con secretos al repo.
```env
# --- Seguridad ---
ENVIRONMENT=production
DEV_LOCAL_AUTH=false
SECRET_KEY=<openssl rand -hex 32>
# --- Sesión local (patrón SIWEB) ---
SESSION_STORE_ENABLED=true
SESSION_IDLE_MINUTES=30
SESSION_MAX_HOURS=10
VALKEY_URL=redis://valkey:6379/0
# --- Workspace / Hub de PRODUCCIÓN (mismo Hub que genera el relay) ---
WORKSPACE_URL=https://<hub-produccion>
HUB_URL=https://<hub-produccion>
INTERNAL_HUB_URL=https://<hub-produccion>
VITE_HUB_URL=https://<hub-produccion>
# --- Keycloak (single-realm / single-client) ---
KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
VITE_KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=aduanasoft
KEYCLOAK_CLIENT_SECRET=<secret del producto provisionado en prod>
# --- Dominio del CRM (mismo origen app + API vía nginx) ---
ORIGIN=https://<dominio-crm-produccion>
APP_PUBLIC_URL=https://<dominio-crm-produccion>
VITE_API_URL=https://<dominio-crm-produccion>/api/
INTERNAL_API_URL=http://backend:8000/api/
CORS_ORIGINS=https://<dominio-crm-produccion>
# --- PostgreSQL ---
CORE_DB_HOST=postgres
CORE_DB_PORT=5432
CORE_DB_NAME=crm_core
CORE_DB_USER=<usuario>
POSTGRES_APP_PASSWORD=<password fuerte>
# --- MinIO / S3 ---
S3_ENDPOINT_URL=http://minio:9000
S3_ACCESS_KEY=<access>
S3_SECRET_KEY=<secret>
S3_BUCKET=crm
S3_REGION=us-east-1
S3_USE_SSL=false
```
> **Importante:** `ENVIRONMENT=production` hace que el bootstrap de permisos solo dé
> `super_admin` al **primer** usuario (no a todos). Es el comportamiento deseado en prod.
---
## 3. Build del frontend — **en CI, no en el server**
La VM de prod no debe compilar el frontend (el build satura RAM). Compila la imagen
en **Jenkins/CI** y publícala al registry, o compílala en una máquina de build:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend
# push al registry interno si aplica
```
El backend usa la imagen/código directamente (uvicorn sin --reload).
---
## 4. Despliegue
```bash
# En el server de prod, en el directorio del proyecto:
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull # si usas registry
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose ps # verifica que todos queden healthy
```
---
## 5. Post-despliegue (datos) — **operador humano, con respaldo hecho**
1. **Verificar migraciones pendientes** (si el proyecto usa Alembic u otro):
revisar y aplicar **solo** las que correspondan, con backup previo.
2. **Seed base** (compañía por tenant + carriles), equivalente a `seed_crm.py`
(`ensure_company` + roles Ventas/Operaciones/Facturación/Consulta).
3. **Sync de permisos** (registra el catálogo por módulo):
```bash
docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()"
```
---
## 6. Verificación / smoke test
```bash
curl -fsS https://<dominio-crm-produccion>/api/health && echo OK
```
- [ ] Login vía **App Launcher del Workspace** entra sin bucle.
- [ ] El dashboard carga compañías del tenant (switcher con el tenant correcto).
- [ ] **Usuarios**: lista carga tras refrescar; alta por invitación crea enlace.
- [ ] **Roles y permisos**: catálogo por módulo carga; marcar un permiso lo guarda
(toast) y persiste al refrescar.
- [ ] Licencia válida (sin bucle 401 / "sesión expirada").
---
## 7. Rollback
1. `docker compose ... up -d` con la **imagen/tag anterior** del frontend/backend.
2. Restaurar `.env` anterior si se cambió.
3. Restaurar el backup de `crm_core` **solo** si hubo cambios de datos irreversibles.
4. `SESSION_STORE_ENABLED=false` revierte al comportamiento previo de sesión sin
redeploy de código (feature-flag), como mitigación rápida.

View File

@@ -0,0 +1,17 @@
# Preview PRIVADO en el servidor (login dev), acceso solo por túnel SSH.
# Publica los puertos ÚNICAMENTE en 127.0.0.1 para que NADA quede expuesto a internet.
# Postgres y MinIO no se publican al host (solo red interna de Docker).
#
# Uso:
# docker compose -f docker-compose.yml -f deploy/docker-compose.preview.yml up -d
services:
backend:
ports: !override
- "127.0.0.1:8000:8000"
frontend:
ports: !override
- "127.0.0.1:5173:5173"
postgres:
ports: !override []
minio:
ports: !override []

View File

@@ -0,0 +1,58 @@
# Despliegue en testing.crm.aduanasoft.com — AUTH REAL vía Hub (SSO relay).
# Frontend: build de PRODUCCIÓN (Dockerfile.prod → node build). Backend: uvicorn sin --reload.
# Puertos SOLO en loopback (nginx del host hace TLS + proxy). Postgres/MinIO no se publican.
#
# 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
args:
VITE_API_URL: ${VITE_API_URL}
VITE_HUB_URL: ${VITE_HUB_URL}
VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL}
VITE_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-master}
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 ["node", "build/index.js"]
ports: !override
- "127.0.0.1:5173:5173"
postgres:
ports: !override []
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

View File

@@ -0,0 +1,48 @@
# ==========================================================================
# testing.crm.aduanasoft.com — AUTH REAL vía Hub (SSO relay). Copia como `.env`
# EN EL SERVIDOR y rellena los <...>. NO subas el .env con secretos al repo.
#
# Flujo: el Hub (App Launcher) redirige a /auth/sso?relay=<uuid>; el CRM
# intercambia el relay en POST {HUB_URL}/api/v1/auth/sso-exchange y entra.
# ==========================================================================
# ---- SEGURIDAD ----
ENVIRONMENT=production
DEV_LOCAL_AUTH=false
SECRET_KEY=<genera: openssl rand -hex 32>
# ---- Workspace / Hub (DEBE ser el MISMO Hub que generó el relay) ----
# Confirmar el host real de producción (workspace.aduanasoft.com o hub.aduanasoft.com):
WORKSPACE_URL=https://<hub-produccion>
HUB_URL=https://<hub-produccion>
INTERNAL_HUB_URL=https://<hub-produccion>
VITE_HUB_URL=https://<hub-produccion>
# ---- Keycloak (arquitectura single-realm / single-client) ----
KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
VITE_KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
KEYCLOAK_REALM=master
KEYCLOAK_CLIENT_ID=aduanasoft
KEYCLOAK_CLIENT_SECRET=<secret del producto provisionado — lo pones tú>
# ---- Dominio del CRM (mismo origen app + API vía nginx) ----
ORIGIN=https://testing.crm.aduanasoft.com
APP_PUBLIC_URL=https://testing.crm.aduanasoft.com
VITE_API_URL=https://testing.crm.aduanasoft.com/api/
INTERNAL_API_URL=http://backend:8000/api/
CORS_ORIGINS=https://testing.crm.aduanasoft.com
# ---- Base de datos (PostgreSQL) ----
CORE_DB_HOST=postgres
CORE_DB_PORT=5432
CORE_DB_NAME=crm_core
CORE_DB_USER=<usuario>
POSTGRES_APP_PASSWORD=<password fuerte>
# ---- MinIO / S3 ----
S3_ENDPOINT_URL=http://minio:9000
S3_ACCESS_KEY=<access>
S3_SECRET_KEY=<secret>
S3_BUCKET=crm
S3_REGION=us-east-1
S3_USE_SSL=false

View File

@@ -0,0 +1,87 @@
# nginx — testing.crm.aduanasoft.com
# CRM Agente de Carga (entorno de PRUEBAS). App (SvelteKit adapter-node :5173) + API (FastAPI :8000)
# servidas en el MISMO origen para evitar CORS. TLS con Let's Encrypt (certbot).
#
# Instalar en el servidor:
# sudo cp testing.crm.aduanasoft.com.conf /etc/nginx/sites-available/
# sudo ln -s /etc/nginx/sites-available/testing.crm.aduanasoft.com.conf /etc/nginx/sites-enabled/
# sudo certbot certonly --webroot -w /var/www/certbot -d testing.crm.aduanasoft.com
# sudo nginx -t && sudo systemctl reload nginx
#
# IMPORTANTE (seguridad): publica los puertos de la app SOLO en loopback del servidor
# (127.0.0.1:5173 y 127.0.0.1:8000) para que nginx sea el único acceso público.
# ---- HTTP: reto ACME + redirección a HTTPS ----
server {
listen 80;
listen [::]:80;
server_name testing.crm.aduanasoft.com;
# Renovación de certificados (webroot)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ---- HTTPS ----
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name testing.crm.aduanasoft.com;
ssl_certificate /etc/letsencrypt/live/testing.crm.aduanasoft.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/testing.crm.aduanasoft.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# Encabezados de seguridad
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Subida de documentos (máx. 25 MB en la app) + margen
client_max_body_size 30m;
# Buffers grandes para headers de respuesta: /auth/sso setea el JWT (fragmentado
# si supera ~4KB) + refresh/id_token/tenant como cookies → el header excede el
# buffer default de nginx (evita "upstream sent too big header" → 502).
proxy_buffer_size 32k;
proxy_buffers 16 32k;
proxy_busy_buffers_size 64k;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# ---- API (FastAPI) — mismo origen: /api/... -> backend :8000 (conserva el path) ----
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
}
# ---- App (SvelteKit adapter-node) — todo lo demás -> frontend :5173 ----
location / {
proxy_pass http://127.0.0.1:5173;
proxy_http_version 1.1;
# WebSocket / upgrade (SSR streaming y HMR si corriera en modo dev)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 120s;
}
}

View File

@@ -38,6 +38,9 @@ ENV INTERNAL_API_URL=${INTERNAL_API_URL}
# Copiar el resto del código
COPY . .
# Subir el límite de heap de Node para el build (VM chico → evita OOM en vite build)
ENV NODE_OPTIONS="--max-old-space-size=3072"
# Construir el proyecto
RUN pnpm run build
@@ -49,10 +52,8 @@ FROM node:22-alpine AS runtime
WORKDIR /app
RUN apk add --no-cache wget
RUN npm config set strict-ssl false && \
npm install -g pnpm
# Runtime SIN dependencias de red (redes restringidas): busybox ya trae wget y
# se arranca con node directo (sin pnpm), así el runtime no toca apk/npm.
# Crear usuario no-root para seguridad antes de copiar con --chown
RUN addgroup -g 1001 -S nodejs
@@ -85,5 +86,5 @@ ENV INTERNAL_API_URL=http://backend:8000/api/
ENTRYPOINT ["/entrypoint.sh"]
# Ejecutar aplicación con Node.js
CMD ["pnpm", "start"]
# Ejecutar aplicación con Node.js (adapter-node, sin pnpm)
CMD ["node", "build/index.js"]

Some files were not shown because too many files have changed in this diff Show More