15 Commits

Author SHA1 Message Date
59fb371f5d docs(crm): abre el changelog del repo y versiona el plan de T2026-08-046
El CRM no tenia CHANGELOG.md. Se crea con el formato del de EFC para que un ticket
que cruza los dos productos se lea igual de los dos lados, y se abre con la entrada
de T2026-08-046 (fases 5-7).

Va en un PR aparte del codigo a proposito: el changelog es el unico archivo
garantizado en colision entre tickets, y metido en cada PR de codigo convierte cada
merge en una resolucion de conflictos en vez de una revision.

La entrada dice tambien lo que NO esta: el lado de EFC del carril todavia no aterriza,
las variables de entorno de produccion quedaron sin tocar y el contrato entre repos
esta afirmado solo desde el CRM. Un changelog que afirma un entregable completo cuando
esta a medias es peor que ninguno.

Se versiona ademas el documento Prompt del ticket en docs/planes/, siguiendo la
convencion de EFC, para que las decisiones del codigo tengan su porque a la vista sin
depender de una carpeta de corrida. Un dato se sustituyo al versionar: un caso de
prueba traia un token con forma de llave de pedimento real y aqui va como dummy; el
caso prueba que esa FORMA se rechace, asi que el numero concreto no aportaba nada.

Refs: T2026-08-046
2026-08-10 07:50:41 -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
88 changed files with 10381 additions and 207 deletions

90
CHANGELOG.md Normal file
View File

@@ -0,0 +1,90 @@
# Changelog — CRM Agentes de Carga
Historial de cambios por ticket (más reciente arriba). Cada entrada: fecha, ticket, tipo, repos
afectados, qué se hizo y por qué. El formato es el mismo que el de `EFC/backend/CHANGELOG.md`, para
que un ticket que cruza los dos productos se lea igual de los dos lados.
Este archivo lo abre la corrida SUNRISE del 2026-08-07: el repo no tenía changelog. Las entradas
llegan en un PR de documentación aparte, nunca dentro del PR de código — el `CHANGELOG.md` es el
único archivo garantizado en colisión entre tickets, y metido en cada PR convierte cada merge en una
resolución de conflictos.
---
## T2026-08-046 (feature, fases 5-7) — Expediente electrónico del CRM y entrega de documentos a EFC
- **Fecha:** 2026-08-07
- **En corto:** Cada solicitud de servicio nace ya con su expediente y su folio propio, que el usuario
ve en cuanto guarda. Los documentos de un embarque se adjuntan en un solo paso y viajan solos al
expediente electrónico; si ese sistema no responde, el documento **no se pierde**: se queda guardado
aquí, la pantalla lo muestra como pendiente de enviar, y se entrega solo cuando el otro lado vuelve.
Quien lo necesite puede abrirlo desde el CRM aunque el archivo ya viva del otro lado.
- **Tipo:** feature
- **Repos:** CRM (backend + frontend). **Las fases 1-4, del lado de EFC, son de otra corrida y todavía
no están** — ver «Lo que falta» al final de la entrada.
- **Branch:** `feature/T2026-08-046` · **PR:** (pendiente de abrir a mano)
- **Inicio:** 2026-08-07T17:40:46 · **Fin:** 2026-08-10T08:00 (la corrida estuvo tres días en pausa
entre medias; el detalle está en `SUNRISE/2026-08-07/Diario-2026-08-07.md`)
- **Con dos migraciones, generadas y SIN aplicar:** `e6f7a8b9c0d1_crm_expedientes.py` y
`f7a8b9c0d1e2` (el outbox del carril). `alembic heads` devuelve **una sola hoja** después de las
dos; el `down_revision` se verificó con `alembic heads`, no se supuso.
- **Contexto:** el CRM guardaba los documentos de sus embarques en su propio almacén y ahí se
quedaban. El expediente electrónico —donde de verdad se consultan— vive en EFC, así que había que
volver a subir cada documento a mano del otro lado, o no subirlo. Y el enlace entre un expediente
del CRM y un pedimento de EFC no podía colgarse de una columna nueva en la tabla de pedimentos: el
expediente del CRM nace **antes** de que exista pedimento alguno.
- **Qué se hizo:**
- **El expediente y su folio.** Una solicitud de servicio crea su expediente en la misma
transacción del alta: si algo falla, no queda ni la solicitud ni un folio quemado. El consecutivo
lo lleva un contador con bloqueo por `(cliente, empresa, mes)` que **reinicia cada mes**, en una
sola sentencia atómica —sin leer-modificar-escribir— para que dos altas simultáneas no puedan
sacar el mismo número. La restricción de unicidad del folio lo garantiza aunque el contador
fallara.
- **El carril de entrega hacia EFC.** Clon del carril que ya usa Anexo22, con sus tres capas de
reintento: el cliente HTTP reintenta lo transitorio y **corta en seco** ante un error del
llamador, la fila pendiente se reintenta con límite de intentos, y un barrido periódico recoge lo
que se quedó atrás. Cuatro capas de idempotencia impiden que un reintento duplique un documento
del otro lado. **La llamada a EFC nunca ocurre dentro de la petición del usuario**: se encola y se
despacha.
- **La subida de un paso y el proxy de descarga.** Adjuntar un documento guarda, registra y encola
en una sola operación, y responde **aunque EFC esté apagado**. Para abrirlo, el CRM hace de proxy
con streaming: el otro sistema nunca entrega una dirección de descarga reutilizable, y reescribir
una dirección ya firmada la invalida.
- **La pantalla.** La ficha del embarque muestra cada documento con su estado —«Pendiente de
enviar», «En expediente», «No se pudo enviar»— y un botón para reintentar el envío cuando algo
falló, sin que nadie tenga que entrar a la base.
- **Tres defectos preexistentes corregidos de paso**, todos en la subida de archivos y todos
autorizados por el ticket: un archivo se leía **entero en memoria** antes de comprobar si excedía el
tamaño máximo (un archivo de 2 GB se bufferizaba solo para rechazarlo); no había lista de
extensiones permitidas, a diferencia del avatar y del centro de ayuda, que sí la tienen; y firmar
una dirección de descarga solo comprobaba el prefijo de la empresa, lo que permitía alcanzar
**cualquier** objeto de esa empresa —facturas, certificados, importaciones— con el permiso más
básico del CRM.
- **Un defecto propio, encontrado y corregido antes de mergear:** el proxy de descarga comprobaba la
respuesta de EFC **demasiado tarde**, ya dentro del envío del archivo. Para entonces la respuesta
del CRM ya había salido como «correcta», así que un documento que EFC no encontraba llegaba al
usuario como un archivo vacío en vez de como un error. Ahora la comprobación ocurre antes de
empezar a responder. Lo destapó una prueba que el ticket pedía y que la fase original no dejó
escrita.
- **Verificación:** `pytest tests/ -q`**215 pasan, 1 se salta**, contra las 70 del punto de
partida; ningún rojo nuevo. En el frontend, la revisión de tipos y las pruebas unitarias quedan
**exactamente** en los mismos números rojos que ya tenían antes de este trabajo (38 y 2, ambos
heredados y ajenos al ticket). Cada verde se vio fallar primero: seis roturas deliberadas,
comprobando que la prueba se pusiera roja nombrando lo que faltaba, y restauradas.
- **Lo que falta, y hay que saberlo antes de mergear:**
- **El lado de EFC (fases 1-4) no está.** Sin él, el carril entrega a un endpoint que todavía no
existe: los documentos se guardan en el CRM y quedan «pendientes de enviar» hasta que aterrice.
Eso es degradar limpio, no romper — pero nadie debería mergear esto creyendo que el circuito está
cerrado de los dos lados.
- **Las variables de entorno de producción no se tocaron.** El worker y el proceso periódico de
producción no ven la dirección de EFC, así que allá el carril queda **apagado** hasta que alguien
las agregue a mano. Está en la lista de pasos manuales del reporte de la corrida.
- **El contrato entre los dos repos está afirmado solo de este lado.** El archivo que lo describe
vive en el CRM y la suite del CRM lo comprueba; EFC debe afirmar su mitad contra una copia
idéntica cuando lleguen sus fases.

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

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

@@ -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,91 @@
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10)."""
from fastapi import APIRouter, Depends, Query
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 .data import INCOTERMS, PARTICIPANT_ROLES
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
@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

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

View File

@@ -0,0 +1,60 @@
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'"))
# 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)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), 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,162 @@
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 (
QuoteCreate,
QuoteItemCreate,
QuoteItemResponse,
QuoteItemUpdate,
QuoteResponse,
QuoteUpdate,
)
router = APIRouter()
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.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.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,281 @@
from datetime import 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 ..service_requests.models import 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 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)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
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

@@ -5,29 +5,42 @@ 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 .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)

View File

@@ -0,0 +1,123 @@
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
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)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
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)
commodity: str | None = None
required_date: date | None = None
destination_agent_id: int | None = None
requirements: 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)."""
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)
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
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)
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)
commodity: str | None = None
required_date: date | None = None
destination_agent_id: int | None = None
requirements: 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,82 @@
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 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)
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,270 @@
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 ..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, 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)
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")
obj = ServiceRequest(
account_id=opp.account_id,
opportunity_id=opp.id,
operation_type=payload.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,
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 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

@@ -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,240 @@
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"),
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))
@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,524 @@
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.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
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
) -> Shipment:
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada."""
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()
shipment = Shipment(
reference=quote.reference,
quote_id=quote.id,
service_request_id=quote.service_request_id,
account_id=quote.account_id,
operation_type=sr.operation_type if sr else None,
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.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

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

@@ -33,9 +33,13 @@ 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,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,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,46 @@
from decimal import Decimal
from api.v1.modules.crm.quotes import service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
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"

View File

@@ -0,0 +1,66 @@
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.service_requests import service
from api.v1.modules.crm.service_requests.dto import (
RateRequestCreate,
ServiceRequestCreate,
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"

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

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,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,33 @@
# 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:
ports: !override
- "127.0.0.1:8000:8000"
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
frontend:
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
volumes: !override []
command: !override ["pnpm", "start"]
ports: !override
- "127.0.0.1:5173:5173"
postgres:
ports: !override []
minio:
ports: !override []

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,81 @@
# 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;
listen [::]:443 ssl;
http2 on;
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;
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
/**
* Cliente API — Direcciones CRM (de un cliente o proveedor)
*/
import { api } from '$lib/api';
import type { Address, AddressInput } from './types';
export const addressesAPI = {
async list(
companyId: number,
params?: { account_id?: number; supplier_id?: number }
): Promise<Address[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.account_id) qs.set('account_id', String(params.account_id));
if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id));
const res = await api.get<Address[]>(`/v1/crm/addresses?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: AddressInput, companyId: number): Promise<Address> {
const res = await api.post<Address>(`/v1/crm/addresses?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<AddressInput>, companyId: number): Promise<Address> {
const res = await api.patch<Address>(`/v1/crm/addresses/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async remove(id: number, companyId: number): Promise<void> {
const res = await api.delete(`/v1/crm/addresses/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -0,0 +1,179 @@
/**
* Cliente API — Proceso comercial (Solicitudes/RFQ, tarifas, Cotizaciones).
*/
import { api } from '$lib/api';
// ---------- Tipos ----------
export type ServiceRequestStatus = 'nueva' | 'contacto' | 'en_analisis' | 'cotizada' | 'aceptada' | 'rechazada' | 'liberada';
export type QuoteStatus = 'borrador' | 'enviada' | 'aceptada' | 'rechazada';
export interface ServiceRequest {
id: number;
reference: string | null;
account_id: number | null;
opportunity_id: number | null;
operation_type: string;
transport_mode: string | null;
service_type: string | null;
incoterm: string | null;
origin: string | null;
destination: string | null;
cargo_type: string | null;
weight: number | null;
volume: number | null;
load_type: string | null;
container_equipment: string | null;
commodity: string | null;
required_date: string | null;
destination_agent_id: number | null;
requirements: string | null;
first_contact_at: string | null;
first_contact_notes: string | null;
status: ServiceRequestStatus;
notes: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ServiceRequestInput = Partial<Omit<ServiceRequest, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>> & {
operation_type: string;
};
export interface RateRequest {
id: number;
service_request_id: number;
supplier_id: number | null;
concept: string;
description: string | null;
status: string;
rate_amount: number | null;
currency: string | null;
valid_until: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type RateRequestInput = Partial<Omit<RateRequest, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
service_request_id: number;
concept: string;
};
export interface Quote {
id: number;
reference: string | null;
service_request_id: number | null;
account_id: number | null;
currency: string;
status: QuoteStatus;
issue_date: string | null;
valid_until: string | null;
total_cost: number;
total_sale: number;
margin: number;
sent_at: string | null;
accepted_at: string | null;
rejected_at: string | null;
notes: string | null;
terms: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type QuoteInput = Partial<Omit<Quote, 'id' | 'status' | 'total_cost' | 'total_sale' | 'margin' | 'sent_at' | 'accepted_at' | 'rejected_at' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
export interface QuoteItem {
id: number;
quote_id: number;
concept: string;
description: string | null;
supplier_id: number | null;
quantity: number;
unit_cost: number;
unit_sale: number;
currency: string | null;
line_cost: number;
line_sale: number;
tenant_id: number;
company_id: number;
}
export type QuoteItemInput = Partial<Omit<QuoteItem, 'id' | 'line_cost' | 'line_sale' | 'tenant_id' | 'company_id'>> & {
quote_id: number;
concept: string;
};
// ---------- Clientes ----------
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
const qs = new URLSearchParams({ company_id: String(companyId) });
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
return qs.toString();
}
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
const res = await p;
if (res.error) throw new Error(res.error);
return res.data as T;
}
export const serviceRequestsAPI = {
list: (companyId: number, params?: { search?: string; status?: string; operation_type?: string; account_id?: number }) =>
unwrap<ServiceRequest[]>(api.get(`/v1/crm/service-requests?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<ServiceRequest>(api.get(`/v1/crm/service-requests/${id}?${qp(companyId)}`)),
create: (data: ServiceRequestInput, companyId: number) => unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests?${qp(companyId)}`, data)),
update: (id: number, data: Partial<ServiceRequestInput>, companyId: number) => unwrap<ServiceRequest>(api.patch(`/v1/crm/service-requests/${id}?${qp(companyId)}`, data)),
registerContact: (id: number, companyId: number, notes?: string | null) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/${id}/contact?${qp(companyId)}`, { notes })),
requote: (id: number, companyId: number) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/${id}/requote?${qp(companyId)}`, {})),
fromOpportunity: (opportunityId: number, data: { operation_type: string; transport_mode?: string; service_type?: string; incoterm?: string; origin?: string; destination?: string; notes?: string | null }, companyId: number) =>
unwrap<ServiceRequest>(api.post(`/v1/crm/service-requests/from-opportunity?${qp(companyId, { opportunity_id: opportunityId })}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/service-requests/${id}?${qp(companyId)}`))
};
export const rateRequestsAPI = {
list: (companyId: number, serviceRequestId?: number) =>
unwrap<RateRequest[]>(api.get(`/v1/crm/rate-requests?${qp(companyId, { service_request_id: serviceRequestId })}`)),
create: (data: RateRequestInput, companyId: number) => unwrap<RateRequest>(api.post(`/v1/crm/rate-requests?${qp(companyId)}`, data)),
update: (id: number, data: Partial<RateRequestInput>, companyId: number) => unwrap<RateRequest>(api.patch(`/v1/crm/rate-requests/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-requests/${id}?${qp(companyId)}`))
};
export const quotesAPI = {
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
unwrap<Quote[]>(api.get(`/v1/crm/quotes?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<Quote>(api.get(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
create: (data: QuoteInput, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes?${qp(companyId)}`, data)),
update: (id: number, data: Partial<QuoteInput>, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}?${qp(companyId)}`, data)),
send: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/send?${qp(companyId)}`, {})),
accept: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/accept?${qp(companyId)}`, {})),
reject: (id: number, companyId: number) => unwrap<Quote>(api.patch(`/v1/crm/quotes/${id}/reject?${qp(companyId)}`, {})),
clone: (id: number, companyId: number) => unwrap<Quote>(api.post(`/v1/crm/quotes/${id}/clone?${qp(companyId)}`, {})),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quotes/${id}?${qp(companyId)}`)),
items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`))
};
// ---------- Catálogos de referencia (Incoterms, participantes) ----------
export interface Incoterm { code: string; name: string; }
export interface ParticipantRole { code: string; label: string; source: string; }
export interface Participant { id: number; source: string; name: string; role: string; roles: string[]; }
export const catalogsAPI = {
incoterms: (companyId: number) => unwrap<Incoterm[]>(api.get(`/v1/crm/catalogs/incoterms?${qp(companyId)}`)),
participantRoles: (companyId: number) => unwrap<ParticipantRole[]>(api.get(`/v1/crm/catalogs/participant-roles?${qp(companyId)}`)),
participants: (companyId: number, role?: string) =>
unwrap<Participant[]>(api.get(`/v1/crm/participants?${qp(companyId, { role })}`))
};
export const quoteItemsAPI = {
create: (data: QuoteItemInput, companyId: number) => unwrap<QuoteItem>(api.post(`/v1/crm/quote-items?${qp(companyId)}`, data)),
update: (id: number, data: Partial<QuoteItemInput>, companyId: number) => unwrap<QuoteItem>(api.patch(`/v1/crm/quote-items/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/quote-items/${id}?${qp(companyId)}`))
};

View File

@@ -0,0 +1,36 @@
/**
* Cliente API — Documentos CRM (de un cliente o proveedor)
*/
import { api } from '$lib/api';
import type { Document, DocumentInput } from './types';
export const documentsAPI = {
async list(
companyId: number,
params?: { account_id?: number; supplier_id?: number }
): Promise<Document[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.account_id) qs.set('account_id', String(params.account_id));
if (params?.supplier_id) qs.set('supplier_id', String(params.supplier_id));
const res = await api.get<Document[]>(`/v1/crm/documents?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: DocumentInput, companyId: number): Promise<Document> {
const res = await api.post<Document>(`/v1/crm/documents?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<DocumentInput>, companyId: number): Promise<Document> {
const res = await api.patch<Document>(`/v1/crm/documents/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async remove(id: number, companyId: number): Promise<void> {
const res = await api.delete(`/v1/crm/documents/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -3,9 +3,13 @@
*/
export * from './types';
export { accountsAPI } from './accounts';
export { suppliersAPI } from './suppliers';
export { contactsAPI } from './contacts';
export { addressesAPI } from './addresses';
export { documentsAPI } from './documents';
export { leadsAPI } from './leads';
export { pipelinesAPI, stagesAPI, type StageInput } from './pipelines';
export { opportunitiesAPI } from './opportunities';
export { activitiesAPI } from './activities';
export { metricsAPI } from './metrics';
export * from './commercial';

View File

@@ -0,0 +1,39 @@
/**
* Cliente API — Proveedores CRM
*/
import { api } from '$lib/api';
import type { Supplier, SupplierInput } from './types';
export const suppliersAPI = {
async list(companyId: number, params?: { search?: string; status?: string }): Promise<Supplier[]> {
const qs = new URLSearchParams({ company_id: String(companyId) });
if (params?.search) qs.set('search', params.search);
if (params?.status) qs.set('status', params.status);
const res = await api.get<Supplier[]>(`/v1/crm/suppliers?${qs}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async get(id: number, companyId: number): Promise<Supplier> {
const res = await api.get<Supplier>(`/v1/crm/suppliers/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
return res.data!;
},
async create(data: SupplierInput, companyId: number): Promise<Supplier> {
const res = await api.post<Supplier>(`/v1/crm/suppliers?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async update(id: number, data: Partial<SupplierInput>, companyId: number): Promise<Supplier> {
const res = await api.patch<Supplier>(`/v1/crm/suppliers/${id}?company_id=${companyId}`, data);
if (res.error) throw new Error(res.error);
return res.data!;
},
async remove(id: number, companyId: number): Promise<void> {
const res = await api.delete(`/v1/crm/suppliers/${id}?company_id=${companyId}`);
if (res.error) throw new Error(res.error);
}
};

View File

@@ -2,51 +2,124 @@
* Tipos del módulo CRM — reflejan los DTOs del backend (api/v1/modules/crm).
*/
export type AccountStatus = 'active' | 'inactive' | 'prospect';
export type AccountStatus = 'active' | 'inactive';
export type RecordType = 'cliente' | 'prospecto';
export type PersonType = 'fisica' | 'moral';
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'unqualified' | 'converted';
export type OpportunityStatus = 'open' | 'won' | 'lost';
export type ActivityType = 'call' | 'meeting' | 'task' | 'email' | 'note';
export type ActivityStatus = 'pending' | 'completed' | 'canceled';
// ---------- Clientes / Prospectos ----------
export interface Account {
id: number;
name: string;
trade_name: string | null;
rfc: string | null;
account_type: string | null;
curp: string | null;
record_type: RecordType;
person_type: string | null;
industry: string | null;
account_type: string | null;
status: AccountStatus;
commercial_classification: string | null;
preferred_contact_method: string | null;
language: string | null;
email: string | null;
phone: string | null;
website: string | null;
tax_regime: string | null;
cfdi_use: string | null;
payment_method: string | null;
payment_form: string | null;
currency: string | null;
credit_limit: number | null;
credit_days: number | null;
commercial_terms: string | null;
patente_aduanal: string | null;
address: string | null;
city: string | null;
state: string | null;
country: string | null;
patente_aduanal: string | null;
status: AccountStatus;
owner_user_id: string | null;
notes: string | null;
internal_notes: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type AccountInput = Partial<Omit<Account, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
export type AccountInput = Partial<Omit<Account, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>> & {
name: string;
};
// ---------- Proveedores ----------
export interface Supplier {
id: number;
name: string;
trade_name: string | null;
rfc: string | null;
curp: string | null;
person_type: string | null;
status: AccountStatus;
classifications: string[];
services_offered: string | null;
coverage: string | null;
countries: string[];
ports: string[];
airports: string[];
customs: string[];
business_hours: string | null;
quote_currency: string | null;
avg_response_time: string | null;
commercial_notes: string | null;
email: string | null;
phone: string | null;
website: string | null;
tax_regime: string | null;
payment_method: string | null;
payment_form: string | null;
credit_limit: number | null;
credit_days: number | null;
commercial_terms: string | null;
notes: string | null;
internal_notes: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type SupplierInput = Partial<Omit<Supplier, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>> & {
name: string;
};
// ---------- Contactos ----------
export interface Contact {
id: number;
account_id: number | null;
supplier_id: number | null;
first_name: string;
last_name: string | null;
email: string | null;
phone: string | null;
mobile: string | null;
job_title: string | null;
department: string | null;
area: string | null;
email: string | null;
phone: string | null;
extension: string | null;
mobile: string | null;
whatsapp: string | null;
is_primary: boolean;
receives_quotes: boolean;
receives_invoices: boolean;
receives_commercial_info: boolean;
status: string;
owner_user_id: string | null;
notes: string | null;
tenant_id: number;
@@ -59,6 +132,54 @@ export type ContactInput = Partial<Omit<Contact, 'id' | 'tenant_id' | 'company_i
first_name: string;
};
// ---------- Direcciones ----------
export interface Address {
id: number;
account_id: number | null;
supplier_id: number | null;
address_type: string;
street: string | null;
ext_number: string | null;
int_number: string | null;
neighborhood: string | null;
postal_code: string | null;
city: string | null;
state: string | null;
country: string | null;
reference_notes: string | null;
is_primary: boolean;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type AddressInput = Partial<Omit<Address, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>>;
// ---------- Documentos ----------
export interface Document {
id: number;
account_id: number | null;
supplier_id: number | null;
doc_type: string;
name: string;
file_key: string | null;
file_url: string | null;
content_type: string | null;
size_bytes: number | null;
uploaded_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type DocumentInput = Partial<Omit<Document, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'uploaded_by'>> & {
doc_type: string;
name: string;
};
// ---------- Prospectos (leads / funnel) ----------
export interface Lead {
id: number;
name: string;
@@ -99,6 +220,7 @@ export interface LeadConvertResult {
opportunity_id: number | null;
}
// ---------- Embudo / Oportunidades ----------
export interface Pipeline {
id: number;
name: string;

View File

@@ -0,0 +1,116 @@
/**
* Cliente API — Facturación y Cobranza.
*/
import { api } from '$lib/api';
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
export interface Invoice {
id: number;
reference: string | null;
shipment_id: number | null;
quote_id: number | null;
account_id: number | null;
currency: string;
status: InvoiceStatus;
issue_date: string | null;
due_date: string | null;
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
paid_amount: number;
balance: number;
ops_cost_total: number | null;
bank_info: string | null;
notes: string | null;
sent_at: string | null;
paid_at: string | null;
pdf_file_key: string | null;
client_reviewed_at: string | null;
client_approved: boolean | null;
review_notes: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' | 'tax_amount' | 'total' | 'paid_amount' | 'balance' | 'sent_at' | 'paid_at' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
export interface InvoiceItem {
id: number;
invoice_id: number;
concept: string;
description: string | null;
quantity: number;
unit_amount: number;
line_total: number;
tenant_id: number;
company_id: number;
}
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
invoice_id: number;
concept: string;
};
export interface Payment {
id: number;
invoice_id: number;
amount: number;
payment_date: string | null;
method: string | null;
reference: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
}
export type PaymentInput = Partial<Omit<Payment, 'id' | 'tenant_id' | 'company_id' | 'created_at'>> & {
invoice_id: number;
amount: number;
};
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
const qs = new URLSearchParams({ company_id: String(companyId) });
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
return qs.toString();
}
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
const res = await p;
if (res.error) throw new Error(res.error);
return res.data as T;
}
export const invoicesAPI = {
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
unwrap<Invoice[]>(api.get(`/v1/fin/invoices?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<Invoice>(api.get(`/v1/fin/invoices/${id}?${qp(companyId)}`)),
create: (data: InvoiceInput, companyId: number) => unwrap<Invoice>(api.post(`/v1/fin/invoices?${qp(companyId)}`, data)),
fromShipment: (shipmentId: number, companyId: number) =>
unwrap<Invoice>(api.post(`/v1/fin/invoices/from-shipment?${qp(companyId, { shipment_id: shipmentId })}`, {})),
update: (id: number, data: Partial<InvoiceInput>, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}?${qp(companyId)}`, data)),
emit: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/emit?${qp(companyId)}`, {})),
send: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/send?${qp(companyId)}`, {})),
pdfUrl: (id: number, companyId: number) => unwrap<{ url: string }>(api.get(`/v1/fin/invoices/${id}/pdf-url?${qp(companyId)}`)),
markClientReview: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/client-review?${qp(companyId)}`, {})),
clientDecision: (id: number, approved: boolean, companyId: number, notes?: string | null) =>
unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/client-decision?${qp(companyId)}`, { approved, notes })),
cancel: (id: number, companyId: number) => unwrap<Invoice>(api.patch(`/v1/fin/invoices/${id}/cancel?${qp(companyId)}`, {})),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/invoices/${id}?${qp(companyId)}`)),
items: (id: number, companyId: number) => unwrap<InvoiceItem[]>(api.get(`/v1/fin/invoices/${id}/items?${qp(companyId)}`)),
payments: (id: number, companyId: number) => unwrap<Payment[]>(api.get(`/v1/fin/invoices/${id}/payments?${qp(companyId)}`))
};
export const invoiceItemsAPI = {
create: (data: InvoiceItemInput, companyId: number) => unwrap<InvoiceItem>(api.post(`/v1/fin/invoice-items?${qp(companyId)}`, data)),
update: (id: number, data: Partial<InvoiceItemInput>, companyId: number) => unwrap<InvoiceItem>(api.patch(`/v1/fin/invoice-items/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/invoice-items/${id}?${qp(companyId)}`))
};
export const paymentsAPI = {
create: (data: PaymentInput, companyId: number) => unwrap<Payment>(api.post(`/v1/fin/payments?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/fin/payments/${id}?${qp(companyId)}`))
};

View File

@@ -0,0 +1,138 @@
/**
* Cliente API — Operaciones (Embarques y documentos de transporte).
*/
import { api } from '$lib/api';
export type ShipmentStatus =
| 'abierta' | 'booking' | 'en_transito' | 'arribado' | 'entregada' | 'cerrada' | 'cancelada';
export interface Shipment {
id: number;
reference: string | null;
quote_id: number | null;
service_request_id: number | null;
account_id: number | null;
operation_type: string | null;
transport_mode: string | null;
service_type: string | null;
incoterm: string | null;
origin: string | null;
destination: string | null;
status: ShipmentStatus;
booking_number: string | null;
carrier_supplier_id: number | null;
ground_carrier_supplier_id: number | null;
customs_agent_id: number | null;
destination_agent_id: number | null;
cutoff_date: string | null;
pickup_at: string | null;
etd: string | null;
previous_etd: string | null;
eta: string | null;
vessel_flight: string | null;
container_number: string | null;
notes: string | null;
actual_cost_total: number | null;
cost_currency: string | null;
closed_at: string | null;
closed_by: string | null;
owner_user_id: string | null;
created_by: string | null;
updated_by: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ShipmentInput = Partial<Omit<Shipment, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
export interface ShipmentEvent {
id: number;
shipment_id: number;
event_type: string | null;
title: string;
kind: string; // hito | decision
status: string; // pendiente | completado | omitido | rechazado | en_correccion
outcome: string | null; // autorizado | rechazado
parent_event_id: number | null;
attempt: number;
position: number;
planned_date: string | null;
actual_date: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ShipmentEventInput = Partial<Omit<ShipmentEvent, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
shipment_id: number;
title: string;
};
export interface ShipmentDocument {
id: number;
shipment_id: number;
doc_kind: string;
doc_type: string;
number: string | null;
issue_date: string | null;
file_url: string | null;
file_key: string | null;
notes: string | null;
tenant_id: number;
company_id: number;
created_at: string;
updated_at: string;
}
export type ShipmentDocumentInput = Partial<Omit<ShipmentDocument, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
shipment_id: number;
doc_type: string;
};
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
const qs = new URLSearchParams({ company_id: String(companyId) });
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
return qs.toString();
}
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
const res = await p;
if (res.error) throw new Error(res.error);
return res.data as T;
}
export const shipmentsAPI = {
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
unwrap<Shipment[]>(api.get(`/v1/ops/shipments?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<Shipment>(api.get(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
create: (data: ShipmentInput, companyId: number) => unwrap<Shipment>(api.post(`/v1/ops/shipments?${qp(companyId)}`, data)),
createFromQuote: (quoteId: number, companyId: number) =>
unwrap<Shipment>(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId })}`, {})),
update: (id: number, data: Partial<ShipmentInput>, companyId: number) => unwrap<Shipment>(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)),
reschedule: (id: number, data: { etd?: string | null; cutoff_date?: string | null; reason?: string | null }, companyId: number) =>
unwrap<Shipment>(api.post(`/v1/ops/shipments/${id}/reschedule?${qp(companyId)}`, data)),
close: (id: number, data: { actual_cost_total: number; cost_currency?: string; notes?: string | null }, companyId: number) =>
unwrap<Shipment>(api.post(`/v1/ops/shipments/${id}/close?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
documents: (shipmentId: number, companyId: number) =>
unwrap<ShipmentDocument[]>(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)),
events: (shipmentId: number, companyId: number) =>
unwrap<ShipmentEvent[]>(api.get(`/v1/ops/shipments/${shipmentId}/events?${qp(companyId)}`)),
seedEvents: (shipmentId: number, companyId: number) =>
unwrap<ShipmentEvent[]>(api.post(`/v1/ops/shipments/${shipmentId}/events/seed?${qp(companyId)}`, {}))
};
export const shipmentEventsAPI = {
create: (data: ShipmentEventInput, companyId: number) => unwrap<ShipmentEvent>(api.post(`/v1/ops/shipment-events?${qp(companyId)}`, data)),
update: (id: number, data: Partial<ShipmentEventInput>, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}?${qp(companyId)}`, data)),
complete: (id: number, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}/complete?${qp(companyId)}`, {})),
decide: (id: number, outcome: 'autorizado' | 'rechazado', companyId: number, notes?: string | null) =>
unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}/decision?${qp(companyId)}`, { outcome, notes })),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-events/${id}?${qp(companyId)}`))
};
export const shipmentDocumentsAPI = {
create: (data: ShipmentDocumentInput, companyId: number) => unwrap<ShipmentDocument>(api.post(`/v1/ops/shipment-documents?${qp(companyId)}`, data)),
update: (id: number, data: Partial<ShipmentDocumentInput>, companyId: number) => unwrap<ShipmentDocument>(api.patch(`/v1/ops/shipment-documents/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-documents/${id}?${qp(companyId)}`))
};

View File

@@ -0,0 +1,33 @@
/**
* Cliente API — subida de archivos a MinIO/S3 (documentos del CRM y Operaciones).
*/
import { api } from '$lib/api';
export interface UploadResult {
file_key: string;
file_url: string;
name: string;
content_type: string | null;
size_bytes: number;
}
/** Sube un archivo (multipart) y devuelve su file_key permanente + URL firmada. */
export async function uploadFile(file: File, companyId: number): Promise<UploadResult> {
const fd = new FormData();
fd.append('file', file);
const res = await api.request<UploadResult>(`/v1/crm/uploads?company_id=${companyId}`, {
method: 'POST',
body: fd
});
if (res.error) throw new Error(res.error);
return res.data!;
}
/** Obtiene una URL firmada fresca para abrir un archivo por su file_key. */
export async function uploadUrl(fileKey: string, companyId: number): Promise<string> {
const res = await api.get<{ url: string }>(
`/v1/crm/uploads/url?key=${encodeURIComponent(fileKey)}&company_id=${companyId}`
);
if (res.error) throw new Error(res.error);
return res.data!.url;
}

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import type { AccountInput } from '$lib/api/crm';
import {
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES,
COMMERCIAL_CLASSIFICATION, CONTACT_METHODS
} from '$lib/components/crm/format';
// `form` es un objeto reactivo del padre; se mutan sus propiedades vía bind:value.
let { form = $bindable(), tab }: { form: AccountInput; tab: string } = $props();
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
{#if tab === 'generales'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre comercial</span><input class={inputCls} bind:value={form.trade_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">RFC</span><input class="font-mono {inputCls}" maxlength="13" bind:value={form.rfc} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">CURP</span><input class="font-mono {inputCls}" maxlength="18" bind:value={form.curp} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de registro</span><select class={inputCls} bind:value={form.record_type}>{#each RECORD_TYPES as r (r.value)}<option value={r.value}>{r.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each PERSON_TYPES as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Giro / Industria</span><input class={inputCls} bind:value={form.industry} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo operativo</span><select class={inputCls} bind:value={form.account_type}><option value={undefined}>—</option>{#each ACCOUNT_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</div>
{:else if tab === 'comercial'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Clasificación</span><select class={inputCls} bind:value={form.commercial_classification}><option value={undefined}>—</option>{#each COMMERCIAL_CLASSIFICATION as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de contacto preferido</span><select class={inputCls} bind:value={form.preferred_contact_method}><option value={undefined}>—</option>{#each CONTACT_METHODS as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Idioma</span><input class={inputCls} bind:value={form.language} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={form.phone} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Sitio web</span><input class={inputCls} bind:value={form.website} /></label>
</div>
{:else if tab === 'fiscal'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Uso de CFDI</span><input class={inputCls} bind:value={form.cfdi_use} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Límite de crédito</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.credit_limit} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Días de crédito</span><input type="number" min="0" class={inputCls} bind:value={form.credit_days} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Patente aduanal</span><input class={inputCls} maxlength="20" bind:value={form.patente_aduanal} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Condiciones comerciales</span><textarea rows="3" class={inputCls} bind:value={form.commercial_terms}></textarea></label>
</div>
{:else if tab === 'observaciones'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Comentarios</span><textarea rows="4" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</div>
{/if}

View File

@@ -0,0 +1,303 @@
<script lang="ts">
import { MapPin, Users, FileText, Plus, Trash2 } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
addressesAPI, contactsAPI, documentsAPI,
type Address, type Contact, type Document, type AddressInput, type ContactInput, type DocumentInput
} from '$lib/api/crm';
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import { toast } from 'svelte-sonner';
// Dueño de los registros relacionados y qué sección mostrar
let {
ownerType,
ownerId,
section = 'all'
}: {
ownerType: 'account' | 'supplier';
ownerId: number;
section?: 'addresses' | 'contacts' | 'documents' | 'all';
} = $props();
const show = (s: 'addresses' | 'contacts' | 'documents') => section === 'all' || section === s;
let addresses = $state<Address[]>([]);
let contacts = $state<Contact[]>([]);
let documents = $state<Document[]>([]);
let activeModal = $state<'address' | 'contact' | 'document' | null>(null);
let saving = $state(false);
let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MX', is_primary: false });
let contactForm = $state<ContactInput>({ first_name: '' });
let documentForm = $state<DocumentInput>({ doc_type: 'constancia_fiscal', name: '' });
let uploading = $state(false);
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !companyId) return;
uploading = true;
try {
const up = await uploadFile(file, companyId);
documentForm = { ...documentForm, file_key: up.file_key, file_url: up.file_url, name: documentForm.name || up.name };
toast.success('Archivo subido');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo subir el archivo');
} finally {
uploading = false;
}
}
async function openDoc(d: Document) {
if (!companyId) return;
try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
if (url) window.open(url, '_blank', 'noopener');
else toast.error('El documento no tiene archivo');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
}
}
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const ownerParam = $derived(ownerType === 'account' ? { account_id: ownerId } : { supplier_id: ownerId });
$effect(() => {
if (companyId && ownerId) void load(companyId);
});
async function load(cid: number) {
try {
[addresses, contacts, documents] = await Promise.all([
addressesAPI.list(cid, ownerParam),
contactsAPI.list(cid, ownerParam),
documentsAPI.list(cid, ownerParam)
]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los datos relacionados');
}
}
function openModal(kind: 'address' | 'contact' | 'document') {
if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MX', is_primary: false };
if (kind === 'contact') contactForm = { first_name: '' };
if (kind === 'document') documentForm = { doc_type: 'constancia_fiscal', name: '' };
activeModal = kind;
}
async function saveAddress(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
saving = true;
try {
await addressesAPI.create({ ...addressForm, ...ownerParam }, companyId);
toast.success('Dirección agregada');
activeModal = null;
await load(companyId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function saveContact(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
if (!contactForm.first_name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await contactsAPI.create({ ...contactForm, ...ownerParam }, companyId);
toast.success('Contacto agregado');
activeModal = null;
await load(companyId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function saveDocument(e: SubmitEvent) {
e.preventDefault();
if (!companyId) return;
if (!documentForm.name?.trim()) { toast.error('El nombre es obligatorio'); return; }
saving = true;
try {
await documentsAPI.create({ ...documentForm, ...ownerParam }, companyId);
toast.success('Documento agregado');
activeModal = null;
await load(companyId);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function removeAddress(a: Address) {
if (!companyId || !confirm('¿Eliminar dirección?')) return;
await addressesAPI.remove(a.id, companyId);
await load(companyId);
}
async function removeContact(c: Contact) {
if (!companyId || !confirm('¿Eliminar contacto?')) return;
await contactsAPI.remove(c.id, companyId);
await load(companyId);
}
async function removeDocument(d: Document) {
if (!companyId || !confirm('¿Eliminar documento?')) return;
await documentsAPI.remove(d.id, companyId);
await load(companyId);
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="grid gap-4">
{#if show('addresses')}
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<Card.Title class="flex items-center gap-2 text-base"><MapPin class="h-4 w-4" /> Direcciones</Card.Title>
<Button size="sm" variant="outline" onclick={() => openModal('address')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header>
<Card.Content>
{#if addresses.length === 0}
<p class="text-sm text-muted-foreground">Sin direcciones.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Domicilio</Table.Head><Table.Head>CP</Table.Head><Table.Head>Ciudad</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each addresses as a (a.id)}
<Table.Row>
<Table.Cell>{labelOf(ADDRESS_TYPES, a.address_type)}{#if a.is_primary}<span class="ml-1 text-[10px] text-primary">(principal)</span>{/if}</Table.Cell>
<Table.Cell class="text-sm">{[a.street, a.ext_number, a.neighborhood].filter(Boolean).join(' ') || '—'}</Table.Cell>
<Table.Cell>{a.postal_code ?? '—'}</Table.Cell>
<Table.Cell>{[a.city, a.state].filter(Boolean).join(', ') || '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeAddress(a)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
{/if}
{#if show('contacts')}
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<Card.Title class="flex items-center gap-2 text-base"><Users class="h-4 w-4" /> Contactos</Card.Title>
<Button size="sm" variant="outline" onclick={() => openModal('contact')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header>
<Card.Content>
{#if contacts.length === 0}
<p class="text-sm text-muted-foreground">Sin contactos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Nombre</Table.Head><Table.Head>Puesto / Área</Table.Head><Table.Head>Email</Table.Head><Table.Head>Teléfono</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each contacts as c (c.id)}
<Table.Row>
<Table.Cell class="font-medium">{c.first_name} {c.last_name ?? ''}{#if c.is_primary}<span class="ml-1 text-[10px] text-primary">(principal)</span>{/if}</Table.Cell>
<Table.Cell class="text-sm">{[c.job_title, labelOf(CONTACT_AREAS, c.area) !== '—' ? labelOf(CONTACT_AREAS, c.area) : null].filter(Boolean).join(' · ') || '—'}</Table.Cell>
<Table.Cell>{c.email ?? '—'}</Table.Cell>
<Table.Cell>{c.phone ?? c.mobile ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeContact(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
{/if}
{#if show('documents')}
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<Card.Title class="flex items-center gap-2 text-base"><FileText class="h-4 w-4" /> Documentos</Card.Title>
<Button size="sm" variant="outline" onclick={() => openModal('document')}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</Card.Header>
<Card.Content>
{#if documents.length === 0}
<p class="text-sm text-muted-foreground">Sin documentos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Nombre</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each documents as d (d.id)}
<Table.Row>
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-medium">{d.name}</Table.Cell>
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>
{#if activeModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (activeModal = null)}>
<div class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if activeModal === 'address'}
<h3 class="mb-4 text-base font-semibold">Nueva dirección</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveAddress}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={addressForm.address_type}>{#each ADDRESS_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Código Postal</span><input class={inputCls} maxlength="10" bind:value={addressForm.postal_code} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Calle</span><input class={inputCls} bind:value={addressForm.street} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Núm. exterior</span><input class={inputCls} bind:value={addressForm.ext_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Núm. interior</span><input class={inputCls} bind:value={addressForm.int_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Colonia</span><input class={inputCls} bind:value={addressForm.neighborhood} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Municipio</span><input class={inputCls} bind:value={addressForm.city} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estado</span><input class={inputCls} bind:value={addressForm.state} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País</span><input class={inputCls} maxlength="2" bind:value={addressForm.country} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Referencias</span><textarea rows="2" class={inputCls} bind:value={addressForm.reference_notes}></textarea></label>
<label class="flex items-center gap-2 text-sm sm:col-span-2"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={addressForm.is_primary} /><span>Domicilio principal</span></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'contact'}
<h3 class="mb-4 text-base font-semibold">Nuevo contacto</h3>
<form class="grid gap-3 sm:grid-cols-2" onsubmit={saveContact}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={contactForm.first_name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Apellidos</span><input class={inputCls} bind:value={contactForm.last_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puesto</span><input class={inputCls} bind:value={contactForm.job_title} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Área</span><select class={inputCls} bind:value={contactForm.area}><option value={undefined}>—</option>{#each CONTACT_AREAS as a (a.value)}<option value={a.value}>{a.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={contactForm.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={contactForm.phone} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Extensión</span><input class={inputCls} bind:value={contactForm.extension} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Celular</span><input class={inputCls} bind:value={contactForm.mobile} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">WhatsApp</span><input class={inputCls} bind:value={contactForm.whatsapp} /></label>
<div class="flex flex-col gap-1 sm:col-span-2">
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.is_primary} /><span>Contacto principal</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.receives_quotes} /><span>Recibe cotizaciones</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.receives_invoices} /><span>Recibe facturas</span></label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={contactForm.receives_commercial_info} /><span>Recibe información comercial</span></label>
</div>
<div class="flex justify-end gap-2 sm:col-span-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{:else if activeModal === 'document'}
<h3 class="mb-4 text-base font-semibold">Nuevo documento</h3>
<form class="grid gap-3" onsubmit={saveDocument}>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span><select class={inputCls} bind:value={documentForm.doc_type}>{#each DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={documentForm.name} required /></label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Archivo {#if uploading}<span class="text-xs text-muted-foreground">(subiendo…)</span>{:else if documentForm.file_key}<span class="text-xs text-emerald-600">(cargado)</span>{/if}</span>
<input type="file" class={inputCls} onchange={onFilePicked} />
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">o URL externa</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" /></label>
<div class="flex justify-end gap-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
</form>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import type { SupplierInput } from '$lib/api/crm';
import { SUPPLIER_CLASSIFICATIONS, COVERAGE, ACCOUNT_STATUS, PERSON_TYPES } from '$lib/components/crm/format';
// listas separadas por coma también son bindables (el padre las convierte a arreglo)
let {
form = $bindable(),
tab,
countriesStr = $bindable(),
portsStr = $bindable(),
airportsStr = $bindable(),
customsStr = $bindable()
}: {
form: SupplierInput;
tab: string;
countriesStr: string;
portsStr: string;
airportsStr: string;
customsStr: string;
} = $props();
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
{#if tab === 'generales'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Razón social *</span><input class={inputCls} bind:value={form.name} required /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre comercial</span><input class={inputCls} bind:value={form.trade_name} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">RFC</span><input class="font-mono {inputCls}" maxlength="13" bind:value={form.rfc} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de persona</span><select class={inputCls} bind:value={form.person_type}><option value={undefined}>—</option>{#each PERSON_TYPES as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
</div>
<div class="mt-4">
<p class="mb-2 text-sm font-medium">Clasificación (una o varias)</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each SUPPLIER_CLASSIFICATIONS as c (c.value)}
<label class="flex items-center gap-2 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" value={c.value} bind:group={form.classifications} /><span>{c.label}</span></label>
{/each}
</div>
</div>
{:else if tab === 'comercial'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cobertura</span><select class={inputCls} bind:value={form.coverage}><option value={undefined}>—</option>{#each COVERAGE as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda de cotización</span><input class={inputCls} maxlength="3" bind:value={form.quote_currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Países (separados por coma)</span><input class={inputCls} bind:value={countriesStr} placeholder="MX, US, PA" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puertos</span><input class={inputCls} bind:value={portsStr} placeholder="Veracruz, Manzanillo" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aeropuertos</span><input class={inputCls} bind:value={airportsStr} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aduanas</span><input class={inputCls} bind:value={customsStr} placeholder="Nuevo Laredo, Colombia" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Horario de atención</span><input class={inputCls} bind:value={form.business_hours} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tiempo prom. de respuesta</span><input class={inputCls} bind:value={form.avg_response_time} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Email</span><input type="email" class={inputCls} bind:value={form.email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={form.phone} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Servicios que ofrece</span><textarea rows="2" class={inputCls} bind:value={form.services_offered}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Observaciones comerciales</span><textarea rows="2" class={inputCls} bind:value={form.commercial_notes}></textarea></label>
</div>
{:else if tab === 'fiscal'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Límite de crédito</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={form.credit_limit} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Días de crédito</span><input type="number" min="0" class={inputCls} bind:value={form.credit_days} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Condiciones comerciales</span><textarea rows="3" class={inputCls} bind:value={form.commercial_terms}></textarea></label>
</div>
{:else if tab === 'observaciones'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Comentarios</span><textarea rows="4" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</div>
{/if}

View File

@@ -1,7 +1,12 @@
/**
* Utilidades de formato y etiquetas legibles para el CRM.
* Utilidades de formato y catálogos de etiquetas para el CRM.
*/
export interface Option {
value: string;
label: string;
}
export function formatMoney(value: number | null | undefined, currency = 'MXN'): string {
if (value === null || value === undefined) return '—';
return new Intl.NumberFormat('es-MX', { style: 'currency', currency }).format(Number(value));
@@ -14,7 +19,34 @@ export function formatDate(value: string | null | undefined): string {
return d.toLocaleDateString('es-MX', { year: 'numeric', month: 'short', day: 'numeric' });
}
export const ACCOUNT_TYPES: { value: string; label: string }[] = [
export function labelOf(list: Option[], value: string | null | undefined): string {
if (!value) return '—';
return list.find((x) => x.value === value)?.label ?? value;
}
// ----- Clientes / Prospectos -----
export const RECORD_TYPES: Option[] = [
{ value: 'cliente', label: 'Cliente' },
{ value: 'prospecto', label: 'Prospecto' }
];
export const PERSON_TYPES: Option[] = [
{ value: 'fisica', label: 'Física' },
{ value: 'moral', label: 'Moral' }
];
export const ACCOUNT_STATUS: Option[] = [
{ value: 'active', label: 'Activo' },
{ value: 'inactive', label: 'Inactivo' }
];
export const COMMERCIAL_CLASSIFICATION: Option[] = [
{ value: 'importador', label: 'Importador' },
{ value: 'exportador', label: 'Exportador' },
{ value: 'ambos', label: 'Importador / Exportador' }
];
export const ACCOUNT_TYPES: Option[] = [
{ value: 'importador', label: 'Importador' },
{ value: 'exportador', label: 'Exportador' },
{ value: 'immex', label: 'IMMEX / Maquila' },
@@ -23,13 +55,78 @@ export const ACCOUNT_TYPES: { value: string; label: string }[] = [
{ value: 'otro', label: 'Otro' }
];
export const ACCOUNT_STATUS: { value: string; label: string }[] = [
{ value: 'active', label: 'Activa' },
{ value: 'prospect', label: 'Prospecto' },
{ value: 'inactive', label: 'Inactiva' }
export const CONTACT_METHODS: Option[] = [
{ value: 'llamada', label: 'Llamada telefónica' },
{ value: 'correo', label: 'Correo electrónico' },
{ value: 'videollamada', label: 'Videoconferencia' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'otro', label: 'Otro' }
];
export const LEAD_SOURCES: { value: string; label: string }[] = [
// ----- Proveedores -----
export const SUPPLIER_CLASSIFICATIONS: Option[] = [
{ value: 'naviera', label: 'Naviera' },
{ value: 'aerolinea', label: 'Aerolínea' },
{ value: 'transportista_terrestre', label: 'Transportista terrestre' },
{ value: 'ferrocarril', label: 'Ferrocarril' },
{ value: 'agente_aduanal', label: 'Agente aduanal' },
{ value: 'agente_carga', label: 'Agente de carga' },
{ value: 'agente_corresponsal', label: 'Agente corresponsal' },
{ value: 'almacen', label: 'Almacén' },
{ value: 'aseguradora', label: 'Aseguradora' },
{ value: 'paqueteria', label: 'Paquetería' },
{ value: 'otro', label: 'Otro' }
];
export const COVERAGE: Option[] = [
{ value: 'nacional', label: 'Nacional' },
{ value: 'internacional', label: 'Internacional' },
{ value: 'ambos', label: 'Nacional e internacional' }
];
// ----- Direcciones -----
export const ADDRESS_TYPES: Option[] = [
{ value: 'fiscal', label: 'Fiscal' },
{ value: 'oficina', label: 'Oficina' },
{ value: 'sucursal', label: 'Sucursal' },
{ value: 'bodega', label: 'Bodega' },
{ value: 'patio', label: 'Patio' },
{ value: 'terminal', label: 'Terminal' },
{ value: 'almacen', label: 'Almacén' }
];
// ----- Documentos -----
export const DOC_TYPES: Option[] = [
{ value: 'constancia_fiscal', label: 'Constancia de Situación Fiscal' },
{ value: 'acta_constitutiva', label: 'Acta Constitutiva' },
{ value: 'identificacion', label: 'Identificación Oficial' },
{ value: 'comprobante_domicilio', label: 'Comprobante de Domicilio' },
{ value: 'contrato', label: 'Contrato' },
{ value: 'presentacion', label: 'Presentación Comercial' },
{ value: 'certificacion', label: 'Certificación' },
{ value: 'licencia', label: 'Licencia' },
{ value: 'convenio', label: 'Convenio' },
{ value: 'tarifario', label: 'Tarifario' },
{ value: 'otro', label: 'Otro' }
];
// ----- Contactos -----
export const CONTACT_AREAS: Option[] = [
{ value: 'ventas', label: 'Ventas' },
{ value: 'operaciones', label: 'Operaciones' },
{ value: 'facturacion', label: 'Facturación' },
{ value: 'cobranza', label: 'Cobranza' },
{ value: 'servicio_cliente', label: 'Servicio al Cliente' },
{ value: 'otro', label: 'Otro' }
];
export const CONTACT_STATUS: Option[] = [
{ value: 'active', label: 'Activo' },
{ value: 'inactive', label: 'Inactivo' }
];
// ----- Prospectos (leads / funnel) -----
export const LEAD_SOURCES: Option[] = [
{ value: 'web', label: 'Web' },
{ value: 'referido', label: 'Referido' },
{ value: 'evento', label: 'Evento' },
@@ -38,7 +135,7 @@ export const LEAD_SOURCES: { value: string; label: string }[] = [
{ value: 'otro', label: 'Otro' }
];
export const LEAD_STATUS: { value: string; label: string }[] = [
export const LEAD_STATUS: Option[] = [
{ value: 'new', label: 'Nuevo' },
{ value: 'contacted', label: 'Contactado' },
{ value: 'qualified', label: 'Calificado' },
@@ -46,7 +143,8 @@ export const LEAD_STATUS: { value: string; label: string }[] = [
{ value: 'converted', label: 'Convertido' }
];
export const ACTIVITY_TYPES: { value: string; label: string }[] = [
// ----- Actividades -----
export const ACTIVITY_TYPES: Option[] = [
{ value: 'call', label: 'Llamada' },
{ value: 'meeting', label: 'Reunión' },
{ value: 'task', label: 'Tarea' },
@@ -54,13 +152,128 @@ export const ACTIVITY_TYPES: { value: string; label: string }[] = [
{ value: 'note', label: 'Nota' }
];
export const ACTIVITY_STATUS: { value: string; label: string }[] = [
export const ACTIVITY_STATUS: Option[] = [
{ value: 'pending', label: 'Pendiente' },
{ value: 'completed', label: 'Completada' },
{ value: 'canceled', label: 'Cancelada' }
];
export function labelOf(list: { value: string; label: string }[], value: string | null): string {
if (!value) return '—';
return list.find((x) => x.value === value)?.label ?? value;
}
// ----- Comercial: Solicitudes / Cotizaciones -----
export const OPERATION_TYPES: Option[] = [
{ value: 'importacion', label: 'Importación' },
{ value: 'exportacion', label: 'Exportación' }
];
export const TRANSPORT_MODES: Option[] = [
{ value: 'maritimo', label: 'Marítimo' },
{ value: 'aereo', label: 'Aéreo' },
{ value: 'terrestre', label: 'Terrestre' },
{ value: 'ferroviario', label: 'Ferroviario' },
{ value: 'multimodal', label: 'Multimodal' }
];
export const SERVICE_TYPES: Option[] = [
{ value: 'puerto_puerto', label: 'Puerto Puerto' },
{ value: 'puerto_puerta', label: 'Puerto Puerta' },
{ value: 'puerta_puerto', label: 'Puerta Puerto' },
{ value: 'puerta_puerta', label: 'Puerta Puerta (Door to Door)' }
];
export const LOAD_TYPES: Option[] = [
{ value: 'FCL', label: 'FCL (contenedor completo)' },
{ value: 'LCL', label: 'LCL (carga consolidada)' }
];
export const SR_STATUS: Option[] = [
{ value: 'nueva', label: 'Nueva' },
{ value: 'contacto', label: 'Contacto realizado' },
{ value: 'en_analisis', label: 'En análisis' },
{ value: 'cotizada', label: 'Cotizada' },
{ value: 'aceptada', label: 'Aceptada' },
{ value: 'rechazada', label: 'Rechazada' },
{ value: 'liberada', label: 'Liberada a operaciones' }
];
export const QUOTE_STATUS: Option[] = [
{ value: 'borrador', label: 'Borrador' },
{ value: 'enviada', label: 'Enviada' },
{ value: 'aceptada', label: 'Aceptada' },
{ value: 'rechazada', label: 'Rechazada' }
];
export const QUOTE_CONCEPTS: Option[] = [
{ value: 'flete_internacional', label: 'Flete internacional' },
{ value: 'transporte_terrestre', label: 'Transporte terrestre' },
{ value: 'despacho_aduanal', label: 'Despacho aduanal' },
{ value: 'gastos_destino', label: 'Gastos en destino' },
{ value: 'otros', label: 'Otros cargos' }
];
export const RATE_STATUS: Option[] = [
{ value: 'solicitada', label: 'Solicitada' },
{ value: 'recibida', label: 'Recibida' },
{ value: 'declinada', label: 'Declinada' }
];
// ----- Operaciones: Embarques -----
export const SHIPMENT_STATUS: Option[] = [
{ value: 'abierta', label: 'Abierta' },
{ value: 'booking', label: 'Booking' },
{ value: 'en_transito', label: 'En tránsito' },
{ value: 'arribado', label: 'Arribado' },
{ value: 'entregada', label: 'Entregada' },
{ value: 'cerrada', label: 'Cerrada' },
{ value: 'cancelada', label: 'Cancelada' }
];
export const DOC_KINDS: Option[] = [
{ value: 'master', label: 'Master' },
{ value: 'house', label: 'House' },
{ value: 'otro', label: 'Otro' }
];
// ----- Bitácora del embarque: hitos y decisiones -----
export const EVENT_STATUS: Option[] = [
{ value: 'pendiente', label: 'Pendiente' },
{ value: 'completado', label: 'Completado' },
{ value: 'omitido', label: 'Omitido' },
{ value: 'rechazado', label: 'Rechazado' },
{ value: 'en_correccion', label: 'En corrección' }
];
export const EVENT_OUTCOME: Option[] = [
{ value: 'autorizado', label: 'Autorizado' },
{ value: 'rechazado', label: 'Rechazado' }
];
// ----- Facturación -----
export const INVOICE_STATUS: Option[] = [
{ value: 'borrador', label: 'Borrador' },
{ value: 'emitida', label: 'Emitida' },
{ value: 'enviada', label: 'Enviada' },
{ value: 'en_revision_cliente', label: 'En revisión del cliente' },
{ value: 'pagada', label: 'Pagada' },
{ value: 'cancelada', label: 'Cancelada' }
];
export const PAYMENT_METHODS: Option[] = [
{ value: 'transferencia', label: 'Transferencia' },
{ value: 'efectivo', label: 'Efectivo' },
{ value: 'cheque', label: 'Cheque' },
{ value: 'tarjeta', label: 'Tarjeta' },
{ value: 'otro', label: 'Otro' }
];
export const SHIPMENT_DOC_TYPES: Option[] = [
{ value: 'MBL', label: 'MBL (Master Bill of Lading)' },
{ value: 'HBL', label: 'HBL (House Bill of Lading)' },
{ value: 'MAWB', label: 'MAWB (Master Air Waybill)' },
{ value: 'HAWB', label: 'HAWB (House Air Waybill)' },
{ value: 'CMR', label: 'CMR (Carta Porte Internacional)' },
{ value: 'factura_comercial', label: 'Factura Comercial' },
{ value: 'packing_list', label: 'Packing List' },
{ value: 'carta_encomienda', label: 'Carta Encomienda' },
{ value: 'carta_garantia', label: 'Carta Garantía' },
{ value: 'certificado_permiso', label: 'Certificado / Permiso' },
{ value: 'otro', label: 'Otro' }
];

View File

@@ -4,6 +4,8 @@ import {
Users,
Shield,
Briefcase,
Ship,
Receipt,
} from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory';
@@ -41,13 +43,32 @@ export function getNavMain(): NavMainItem[] {
icon: Briefcase,
items: [
{ title: 'Panel', url: '/dashboard/crm' },
{ title: 'Cuentas', url: '/dashboard/crm/cuentas' },
{ title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' },
{ title: 'Proveedores', url: '/dashboard/crm/proveedores' },
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Prospectos', url: '/dashboard/crm/prospectos' },
{ title: 'Solicitudes', url: '/dashboard/crm/solicitudes' },
{ title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
],
},
{
title: 'Operaciones',
url: '/dashboard/ops/embarques',
icon: Ship,
items: [
{ title: 'Embarques', url: '/dashboard/ops/embarques' },
],
},
{
title: 'Facturación',
url: '/dashboard/fin/facturas',
icon: Receipt,
items: [
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
],
},
{
title: 'Usuarios',
url: '/dashboard/users',

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { Receipt, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { quotesAPI, type Quote } from '$lib/api/crm';
import { QUOTE_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<Quote[]>([]);
let loading = $state(false);
let search = $state('');
let statusFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
items.filter((q) => {
if (statusFilter && q.status !== statusFilter) return false;
if (search.trim()) return `${q.reference ?? ''}`.toLowerCase().includes(search.trim().toLowerCase());
return true;
})
);
const statusClass: Record<string, string> = {
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
enviada: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
aceptada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
rechazada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await quotesAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las cotizaciones');
} finally {
loading = false;
}
}
async function remove(q: Quote) {
if (!companyId || !confirm(`¿Eliminar la cotización ${q.reference ?? q.id}?`)) return;
try {
await quotesAPI.remove(q.id, companyId);
toast.success('Cotización eliminada');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Cotizaciones</h1>
<p class="mt-1 text-sm text-muted-foreground">Propuestas económicas con conceptos de costo y venta.</p>
</div>
<Button href="/dashboard/crm/cotizaciones/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nueva cotización</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio…" bind:value={search} />
</div>
<select class={inputCls} bind:value={statusFilter}>
<option value="">Todos los estatus</option>
{#each QUOTE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin cotizaciones.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Total venta</Table.Head>
<Table.Head class="text-right">Margen</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as q (q.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/cotizaciones/${q.id}`}>{q.reference ?? `#${q.id}`}</a></Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[q.status] ?? ''}">{labelOf(QUOTE_STATUS, q.status)}</span></Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.total_sale, q.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(q.margin, q.currency)}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/crm/cotizaciones/${q.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onclick={() => remove(q)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,251 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
quotesAPI, quoteItemsAPI, accountsAPI, serviceRequestsAPI, suppliersAPI,
type Quote, type QuoteInput, type QuoteItem, type QuoteItemInput, type Account, type ServiceRequest, type Supplier
} from '$lib/api/crm';
import { shipmentsAPI } from '$lib/api/ops';
import { QUOTE_STATUS, QUOTE_CONCEPTS, labelOf, formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const quoteId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let quote = $state<Quote | null>(null);
let items = $state<QuoteItem[]>([]);
let accounts = $state<Account[]>([]);
let requests = $state<ServiceRequest[]>([]);
let suppliers = $state<Supplier[]>([]);
let form = $state<QuoteInput>({});
let tab = $state('conceptos');
let loading = $state(false);
let saving = $state(false);
let adding = $state(false);
let busy = $state(false);
let newItem = $state<QuoteItemInput>({ quote_id: 0, concept: 'flete_internacional', quantity: 1, unit_cost: 0, unit_sale: 0 });
$effect(() => {
const cid = companyId;
const id = quoteId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
[quote, items, accounts, requests, suppliers] = await Promise.all([
quotesAPI.get(id, cid),
quotesAPI.items(id, cid),
accountsAPI.list(cid),
serviceRequestsAPI.list(cid),
suppliersAPI.list(cid)
]);
form = { ...quote };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la cotización');
} finally {
loading = false;
}
}
async function reload() {
if (companyId) {
[quote, items] = await Promise.all([quotesAPI.get(quoteId, companyId), quotesAPI.items(quoteId, companyId)]);
form = { ...quote };
}
}
async function saveHeader() {
if (!companyId || !quote) return;
saving = true;
try {
quote = await quotesAPI.update(quote.id, form, companyId);
form = { ...quote };
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
function startAdd() {
newItem = { quote_id: quoteId, concept: 'flete_internacional', quantity: 1, unit_cost: 0, unit_sale: 0, currency: quote?.currency };
adding = true;
}
async function saveItem() {
if (!companyId) return;
try {
await quoteItemsAPI.create({ ...newItem, quote_id: quoteId }, companyId);
toast.success('Concepto agregado');
adding = false;
await reload();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo agregar');
}
}
async function removeItem(it: QuoteItem) {
if (!companyId || !confirm('¿Eliminar concepto?')) return;
await quoteItemsAPI.remove(it.id, companyId);
await reload();
}
async function doAction(action: 'send' | 'accept' | 'reject') {
if (!companyId || !quote) return;
busy = true;
try {
quote = await quotesAPI[action](quote.id, companyId);
form = { ...quote };
toast.success('Cotización actualizada');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
busy = false;
}
}
async function release() {
if (!companyId || !quote) return;
if (!confirm('¿Liberar esta cotización a Operaciones (crear embarque)?')) return;
busy = true;
try {
const shipment = await shipmentsAPI.createFromQuote(quote.id, companyId);
toast.success('Embarque creado');
await goto(`/dashboard/ops/embarques/${shipment.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo liberar');
} finally {
busy = false;
}
}
async function clone() {
if (!companyId || !quote) return;
busy = true;
try {
const nq = await quotesAPI.clone(quote.id, companyId);
toast.success('Cotización clonada como borrador');
await goto(`/dashboard/crm/cotizaciones/${nq.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo clonar');
} finally {
busy = false;
}
}
function supplierName(id: number | null | undefined): string {
return suppliers.find((s) => s.id === id)?.name ?? '—';
}
const statusClass: Record<string, string> = {
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
enviada: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
aceptada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
rechazada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/cotizaciones"><ArrowLeft class="mr-1 h-4 w-4" /> Cotizaciones</Button>
{#if loading && !quote}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if quote}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> {quote.reference ?? `Cotización #${quote.id}`}</h1>
<p class="mt-1 text-sm">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[quote.status] ?? ''}">{labelOf(QUOTE_STATUS, quote.status)}</span>
</p>
</div>
<div class="flex flex-wrap gap-2">
{#if quote.status === 'borrador'}
<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar</Button>
{/if}
{#if quote.status === 'enviada'}
<Button size="sm" variant="outline" onclick={() => doAction('accept')} disabled={busy}><Check class="mr-1 h-4 w-4" /> Aceptar</Button>
<Button size="sm" variant="outline" onclick={() => doAction('reject')} disabled={busy}><X class="mr-1 h-4 w-4" /> Rechazar</Button>
{/if}
{#if quote.status === 'aceptada'}
<Button size="sm" onclick={release} disabled={busy}><Ship class="mr-1 h-4 w-4" /> Liberar a Operaciones</Button>
{/if}
{#if quote.status === 'rechazada'}
<Button size="sm" variant="outline" onclick={clone} disabled={busy}><Plus class="mr-1 h-4 w-4" /> Re-cotizar (clonar)</Button>
{/if}
</div>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<Card.Root><Card.Header><Card.Description>Costo total</Card.Description><Card.Title class="text-xl">{formatMoney(quote.total_cost, quote.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Venta total</Card.Description><Card.Title class="text-xl">{formatMoney(quote.total_sale, quote.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Margen</Card.Description><Card.Title class="text-xl text-emerald-600">{formatMoney(quote.margin, quote.currency)}</Card.Title></Card.Header></Card.Root>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each [{ id: 'conceptos', label: 'Conceptos' }, { id: 'datos', label: 'Datos' }] as t (t.id)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'conceptos'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startAdd}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
{#if adding}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Proveedor</span><select class={inputCls} bind:value={newItem.supplier_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Costo unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_cost} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Venta unitaria</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_sale} /></label>
<div class="flex justify-end gap-2 sm:col-span-3"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
</div>
{/if}
{#if items.length === 0}
<p class="text-sm text-muted-foreground">Sin conceptos. Agrega el flete, despacho, gastos, etc.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Proveedor</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Costo</Table.Head><Table.Head class="text-right">Venta</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell>{supplierName(it.supplier_id)}</Table.Cell>
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.line_cost, quote.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.line_sale, quote.currency)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
{:else}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Términos y condiciones</span><textarea rows="2" class={inputCls} bind:value={form.terms}></textarea></label>
</div>
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={saveHeader} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { ArrowLeft, Receipt } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { quotesAPI, accountsAPI, serviceRequestsAPI, type QuoteInput, type Account, type ServiceRequest } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
let form = $state<QuoteInput>({ currency: 'USD' });
let accounts = $state<Account[]>([]);
let requests = $state<ServiceRequest[]>([]);
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
$effect(() => {
const cid = companyId;
if (!cid) return;
void (async () => {
[accounts, requests] = await Promise.all([accountsAPI.list(cid), serviceRequestsAPI.list(cid)]);
})();
});
async function save() {
if (!companyId) return;
saving = true;
try {
const created = await quotesAPI.create(form, companyId);
toast.success('Cotización creada');
await goto(`/dashboard/crm/cotizaciones/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear la cotización');
} finally {
saving = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/cotizaciones"><ArrowLeft class="mr-1 h-4 w-4" /> Cotizaciones</Button>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Nueva cotización</h1>
<Card.Root>
<Card.Content class="pt-6">
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="COT-0001" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Solicitud (RFQ)</span><select class={inputCls} bind:value={form.service_request_id}><option value={undefined}>—</option>{#each requests as r (r.id)}<option value={r.id}>{r.reference ?? `#${r.id}`}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia</span><input type="date" class={inputCls} bind:value={form.valid_until} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
</div>
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/cotizaciones">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear y agregar conceptos'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,31 +1,29 @@
<script lang="ts">
import { Building2, Plus, Pencil, Trash2, Search } from '@lucide/svelte';
import { Building2, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type Account, type AccountInput } from '$lib/api/crm';
import { ACCOUNT_TYPES, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
import { accountsAPI, type Account } from '$lib/api/crm';
import { ACCOUNT_STATUS, RECORD_TYPES, COMMERCIAL_CLASSIFICATION, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<Account[]>([]);
let loading = $state(false);
let search = $state('');
let modalOpen = $state(false);
let saving = $state(false);
let editingId = $state<number | null>(null);
let form = $state<AccountInput>({ name: '', status: 'active', country: 'MX' });
let recordFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
search.trim()
? items.filter((a) =>
`${a.name} ${a.trade_name ?? ''} ${a.rfc ?? ''}`
.toLowerCase()
.includes(search.trim().toLowerCase())
)
: items
items.filter((a) => {
if (recordFilter && a.record_type !== recordFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
return `${a.name} ${a.trade_name ?? ''} ${a.rfc ?? ''}`.toLowerCase().includes(q);
}
return true;
})
);
$effect(() => {
@@ -39,66 +37,26 @@
try {
items = await accountsAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las cuentas');
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los clientes');
} finally {
loading = false;
}
}
function openCreate() {
editingId = null;
form = { name: '', status: 'active', country: 'MX' };
modalOpen = true;
}
function openEdit(a: Account) {
editingId = a.id;
form = { ...a };
modalOpen = true;
}
async function save(event: SubmitEvent) {
event.preventDefault();
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('El nombre es obligatorio');
return;
}
saving = true;
try {
if (editingId) {
await accountsAPI.update(editingId, form, companyId);
toast.success('Cuenta actualizada');
} else {
await accountsAPI.create(form, companyId);
toast.success('Cuenta creada');
}
modalOpen = false;
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar la cuenta');
} finally {
saving = false;
}
}
async function remove(a: Account) {
if (!companyId) return;
if (!confirm(`¿Eliminar la cuenta "${a.name}"?`)) return;
if (!confirm(`¿Eliminar "${a.name}"?`)) return;
try {
await accountsAPI.remove(a.id, companyId);
toast.success('Cuenta eliminada');
toast.success('Cliente eliminado');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar la cuenta');
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
const statusClass: Record<string, string> = {
active: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
prospect: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400',
inactive: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
};
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
@@ -106,41 +64,43 @@
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building2 class="h-6 w-6" />
Cuentas
Clientes / Prospectos
</h1>
<p class="mt-1 text-sm text-muted-foreground">Empresas cliente y prospectos.</p>
<p class="mt-1 text-sm text-muted-foreground">Catálogo de clientes y prospectos.</p>
</div>
<Button onclick={openCreate} disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nueva cuenta
<Button href="/dashboard/crm/cuentas/nuevo" disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nuevo registro
</Button>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input
class="w-full rounded-md border bg-transparent py-2 pl-8 pr-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Buscar por nombre o RFC…"
bind:value={search}
/>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por nombre o RFC…" bind:value={search} />
</div>
<select class={inputCls} bind:value={recordFilter}>
<option value="">Todos</option>
{#each RECORD_TYPES as r (r.value)}<option value={r.value}>{r.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin cuentas registradas.</p>
<p class="py-6 text-center text-sm text-muted-foreground">Sin registros.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Nombre</Table.Head>
<Table.Head>RFC</Table.Head>
<Table.Head>Razón social</Table.Head>
<Table.Head>Tipo</Table.Head>
<Table.Head>Estado</Table.Head>
<Table.Head>Teléfono</Table.Head>
<Table.Head>RFC</Table.Head>
<Table.Head>Clasificación</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
@@ -148,20 +108,20 @@
{#each filtered as a (a.id)}
<Table.Row>
<Table.Cell class="font-medium">
{a.name}
<a class="hover:underline" href={`/dashboard/crm/cuentas/${a.id}`}>{a.name}</a>
{#if a.trade_name}<span class="block text-xs text-muted-foreground">{a.trade_name}</span>{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs">{a.rfc ?? '—'}</Table.Cell>
<Table.Cell>{labelOf(ACCOUNT_TYPES, a.account_type)}</Table.Cell>
<Table.Cell>
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[a.status] ?? ''}">
{labelOf(ACCOUNT_STATUS, a.status)}
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {a.record_type === 'cliente' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400'}">
{labelOf(RECORD_TYPES, a.record_type)}
</span>
</Table.Cell>
<Table.Cell>{a.phone ?? '—'}</Table.Cell>
<Table.Cell class="font-mono text-xs">{a.rfc ?? '—'}</Table.Cell>
<Table.Cell>{labelOf(COMMERCIAL_CLASSIFICATION, a.commercial_classification)}</Table.Cell>
<Table.Cell>{labelOf(ACCOUNT_STATUS, a.status)}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" onclick={() => openEdit(a)} aria-label="Editar">
<Pencil class="h-4 w-4" />
<Button variant="ghost" size="sm" href={`/dashboard/crm/cuentas/${a.id}`} aria-label="Abrir">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(a)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" />
@@ -176,75 +136,3 @@
</Card.Content>
</Card.Root>
</div>
{#if modalOpen}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="presentation"
onclick={() => (modalOpen = false)}
>
<div
class="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg"
role="dialog"
aria-modal="true"
onclick={(e) => e.stopPropagation()}
>
<h2 class="mb-4 text-lg font-semibold">{editingId ? 'Editar cuenta' : 'Nueva cuenta'}</h2>
<form class="grid gap-4 sm:grid-cols-2" onsubmit={save}>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Razón social *</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.name} required />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Nombre comercial</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.trade_name} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">RFC</span>
<input class="rounded-md border bg-transparent px-3 py-2 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" maxlength="13" bind:value={form.rfc} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tipo</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.account_type}>
<option value={undefined}>—</option>
{#each ACCOUNT_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Estado</span>
<select class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.status}>
{#each ACCOUNT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Patente aduanal</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" maxlength="20" bind:value={form.patente_aduanal} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Email</span>
<input type="email" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.email} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Teléfono</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.phone} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Ciudad</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.city} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Estado (entidad)</span>
<input class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.state} />
</label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Notas</span>
<textarea rows="3" class="rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" bind:value={form.notes}></textarea>
</label>
<div class="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>Cancelar</Button>
<Button type="submit" disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { ArrowLeft, Building2 } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import AccountFields from '$lib/components/crm/AccountFields.svelte';
import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type Account, type AccountInput } from '$lib/api/crm';
import { RECORD_TYPES, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
const TABS: TabDef[] = [
{ id: 'generales', label: 'Datos generales', kind: 'info' },
{ id: 'comercial', label: 'Comercial', kind: 'info' },
{ id: 'fiscal', label: 'Fiscal', kind: 'info' },
{ id: 'observaciones', label: 'Observaciones', kind: 'info' },
{ id: 'direcciones', label: 'Direcciones', kind: 'related', section: 'addresses' },
{ id: 'contactos', label: 'Contactos', kind: 'related', section: 'contacts' },
{ id: 'documentos', label: 'Documentos', kind: 'related', section: 'documents' }
];
const accountId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let account = $state<Account | null>(null);
let form = $state<AccountInput>({ name: '' });
let tab = $state('generales');
let loading = $state(false);
let saving = $state(false);
const activeTab = $derived(TABS.find((t) => t.id === tab) ?? TABS[0]);
$effect(() => {
const cid = companyId;
const id = accountId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
account = await accountsAPI.get(id, cid);
form = { ...account };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el cliente');
} finally {
loading = false;
}
}
async function save() {
if (!companyId || !account) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
return;
}
saving = true;
try {
account = await accountsAPI.update(account.id, form, companyId);
form = { ...account };
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los cambios');
} finally {
saving = false;
}
}
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/cuentas"><ArrowLeft class="mr-1 h-4 w-4" /> Clientes</Button>
{#if loading && !account}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if account}
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building2 class="h-6 w-6" />
{account.name}
</h1>
<p class="mt-1 text-sm text-muted-foreground">
{labelOf(RECORD_TYPES, account.record_type)} · {labelOf(ACCOUNT_STATUS, account.status)}
{#if account.rfc}· <span class="font-mono">{account.rfc}</span>{/if}
</p>
</div>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each TABS as t (t.id)}
<button
type="button"
class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (tab = t.id)}
>
{t.label}
</button>
{/each}
</div>
{#if activeTab.kind === 'info'}
<AccountFields bind:form tab={tab} />
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
</div>
{:else if activeTab.section}
<RelatedManager ownerType="account" ownerId={account.id} section={activeTab.section} />
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { ArrowLeft, Building2 } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import AccountFields from '$lib/components/crm/AccountFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type AccountInput } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
const TABS = [
{ id: 'generales', label: 'Datos generales' },
{ id: 'comercial', label: 'Comercial' },
{ id: 'fiscal', label: 'Fiscal' },
{ id: 'observaciones', label: 'Observaciones' }
];
let form = $state<AccountInput>({ name: '', record_type: 'cliente', status: 'active', country: 'MX' });
let tab = $state('generales');
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
async function save() {
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
tab = 'generales';
return;
}
saving = true;
try {
const created = await accountsAPI.create(form, companyId);
toast.success('Cliente creado');
await goto(`/dashboard/crm/cuentas/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear el cliente');
} finally {
saving = false;
}
}
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/cuentas"><ArrowLeft class="mr-1 h-4 w-4" /> Clientes</Button>
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building2 class="h-6 w-6" /> Nuevo cliente / prospecto
</h1>
<p class="mt-1 text-sm text-muted-foreground">Al guardar podrás agregar direcciones, contactos y documentos.</p>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each TABS as t (t.id)}
<button
type="button"
class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (tab = t.id)}
>
{t.label}
</button>
{/each}
</div>
<AccountFields bind:form {tab} />
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/cuentas">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Target, Plus } from '@lucide/svelte';
import { Target, Plus, FileOutput } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
@@ -8,6 +9,7 @@
pipelinesAPI,
stagesAPI,
accountsAPI,
serviceRequestsAPI,
type Opportunity,
type OpportunityInput,
type Pipeline,
@@ -150,6 +152,20 @@
}
}
async function convertToRequest(opp: Opportunity) {
if (!companyId) return;
const op = window.prompt('Convertir a solicitud — tipo de operación (importacion / exportacion):', 'exportacion');
if (!op) return;
const operation_type = op.trim().toLowerCase() === 'importacion' ? 'importacion' : 'exportacion';
try {
const sr = await serviceRequestsAPI.fromOpportunity(opp.id, { operation_type }, companyId);
toast.success('Solicitud creada desde la oportunidad');
await goto(`/dashboard/crm/solicitudes/${sr.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo convertir');
}
}
function onDragStart(event: DragEvent, id: number) {
draggingId = id;
event.dataTransfer?.setData('text/plain', String(id));
@@ -248,6 +264,9 @@
<span class="text-[10px] text-muted-foreground">{opp.probability}%</span>
{/if}
</div>
<button type="button" class="mt-2 inline-flex items-center gap-1 text-[11px] text-primary hover:underline" onclick={() => convertToRequest(opp)}>
<FileOutput class="h-3 w-3" /> Convertir a solicitud
</button>
</div>
{/each}
</div>

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import { Truck, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { suppliersAPI, type Supplier } from '$lib/api/crm';
import { SUPPLIER_CLASSIFICATIONS, COVERAGE, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<Supplier[]>([]);
let loading = $state(false);
let search = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
search.trim()
? items.filter((s) => `${s.name} ${s.trade_name ?? ''} ${s.rfc ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()))
: items
);
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await suppliersAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los proveedores');
} finally {
loading = false;
}
}
async function remove(s: Supplier) {
if (!companyId) return;
if (!confirm(`¿Eliminar "${s.name}"?`)) return;
try {
await suppliersAPI.remove(s.id, companyId);
toast.success('Proveedor eliminado');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
function classNames(s: Supplier): string {
return (s.classifications ?? []).map((c) => labelOf(SUPPLIER_CLASSIFICATIONS, c)).join(', ') || '—';
}
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Truck class="h-6 w-6" />
Proveedores
</h1>
<p class="mt-1 text-sm text-muted-foreground">Navieras, aerolíneas, transportistas, agentes y más.</p>
</div>
<Button href="/dashboard/crm/proveedores/nuevo" disabled={!companyId}>
<Plus class="mr-1 h-4 w-4" /> Nuevo proveedor
</Button>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por nombre o RFC…" bind:value={search} />
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin proveedores registrados.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Razón social</Table.Head>
<Table.Head>Clasificación</Table.Head>
<Table.Head>Cobertura</Table.Head>
<Table.Head>RFC</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as s (s.id)}
<Table.Row>
<Table.Cell class="font-medium">
<a class="hover:underline" href={`/dashboard/crm/proveedores/${s.id}`}>{s.name}</a>
{#if s.trade_name}<span class="block text-xs text-muted-foreground">{s.trade_name}</span>{/if}
</Table.Cell>
<Table.Cell class="text-xs">{classNames(s)}</Table.Cell>
<Table.Cell>{labelOf(COVERAGE, s.coverage)}</Table.Cell>
<Table.Cell class="font-mono text-xs">{s.rfc ?? '—'}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/crm/proveedores/${s.id}`} aria-label="Abrir">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { ArrowLeft, Truck } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import SupplierFields from '$lib/components/crm/SupplierFields.svelte';
import RelatedManager from '$lib/components/crm/RelatedManager.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { suppliersAPI, type Supplier, type SupplierInput } from '$lib/api/crm';
import { COVERAGE, ACCOUNT_STATUS, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
const TABS: TabDef[] = [
{ id: 'generales', label: 'Datos generales', kind: 'info' },
{ id: 'comercial', label: 'Comercial', kind: 'info' },
{ id: 'fiscal', label: 'Fiscal', kind: 'info' },
{ id: 'observaciones', label: 'Observaciones', kind: 'info' },
{ id: 'direcciones', label: 'Direcciones', kind: 'related', section: 'addresses' },
{ id: 'contactos', label: 'Contactos', kind: 'related', section: 'contacts' },
{ id: 'documentos', label: 'Documentos', kind: 'related', section: 'documents' }
];
const supplierId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let supplier = $state<Supplier | null>(null);
let form = $state<SupplierInput>({ name: '', classifications: [] });
let countriesStr = $state('');
let portsStr = $state('');
let airportsStr = $state('');
let customsStr = $state('');
let tab = $state('generales');
let loading = $state(false);
let saving = $state(false);
const activeTab = $derived(TABS.find((t) => t.id === tab) ?? TABS[0]);
const toArr = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
const toStr = (a: string[] | null | undefined): string => (a ?? []).join(', ');
$effect(() => {
const cid = companyId;
const id = supplierId;
if (!cid || !id) return;
void load(cid, id);
});
function hydrate(s: Supplier) {
form = { ...s, classifications: [...(s.classifications ?? [])] };
countriesStr = toStr(s.countries);
portsStr = toStr(s.ports);
airportsStr = toStr(s.airports);
customsStr = toStr(s.customs);
}
async function load(cid: number, id: number) {
loading = true;
try {
supplier = await suppliersAPI.get(id, cid);
hydrate(supplier);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el proveedor');
} finally {
loading = false;
}
}
async function save() {
if (!companyId || !supplier) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
return;
}
saving = true;
try {
const payload: SupplierInput = {
...form,
countries: toArr(countriesStr),
ports: toArr(portsStr),
airports: toArr(airportsStr),
customs: toArr(customsStr)
};
supplier = await suppliersAPI.update(supplier.id, payload, companyId);
hydrate(supplier);
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los cambios');
} finally {
saving = false;
}
}
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/proveedores"><ArrowLeft class="mr-1 h-4 w-4" /> Proveedores</Button>
{#if loading && !supplier}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if supplier}
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Truck class="h-6 w-6" />
{supplier.name}
</h1>
<p class="mt-1 text-sm text-muted-foreground">
{labelOf(COVERAGE, supplier.coverage)} · {labelOf(ACCOUNT_STATUS, supplier.status)}
{#if supplier.rfc}· <span class="font-mono">{supplier.rfc}</span>{/if}
</p>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each TABS as t (t.id)}
<button
type="button"
class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (tab = t.id)}
>
{t.label}
</button>
{/each}
</div>
{#if activeTab.kind === 'info'}
<SupplierFields bind:form {tab} bind:countriesStr bind:portsStr bind:airportsStr bind:customsStr />
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
</div>
{:else if activeTab.section}
<RelatedManager ownerType="supplier" ownerId={supplier.id} section={activeTab.section} />
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { ArrowLeft, Truck } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import SupplierFields from '$lib/components/crm/SupplierFields.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { suppliersAPI, type SupplierInput } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
const TABS = [
{ id: 'generales', label: 'Datos generales' },
{ id: 'comercial', label: 'Comercial' },
{ id: 'fiscal', label: 'Fiscal' },
{ id: 'observaciones', label: 'Observaciones' }
];
let form = $state<SupplierInput>({ name: '', status: 'active', classifications: [] });
let countriesStr = $state('');
let portsStr = $state('');
let airportsStr = $state('');
let customsStr = $state('');
let tab = $state('generales');
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const toArr = (s: string): string[] => s.split(',').map((t) => t.trim()).filter(Boolean);
async function save() {
if (!companyId) return;
if (!form.name?.trim()) {
toast.error('La razón social es obligatoria');
tab = 'generales';
return;
}
saving = true;
try {
const payload: SupplierInput = {
...form,
countries: toArr(countriesStr),
ports: toArr(portsStr),
airports: toArr(airportsStr),
customs: toArr(customsStr)
};
const created = await suppliersAPI.create(payload, companyId);
toast.success('Proveedor creado');
await goto(`/dashboard/crm/proveedores/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear el proveedor');
} finally {
saving = false;
}
}
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/proveedores"><ArrowLeft class="mr-1 h-4 w-4" /> Proveedores</Button>
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Truck class="h-6 w-6" /> Nuevo proveedor
</h1>
<p class="mt-1 text-sm text-muted-foreground">Al guardar podrás agregar direcciones, contactos y documentos.</p>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each TABS as t (t.id)}
<button
type="button"
class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (tab = t.id)}
>
{t.label}
</button>
{/each}
</div>
<SupplierFields bind:form {tab} bind:countriesStr bind:portsStr bind:airportsStr bind:customsStr />
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/proveedores">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { FileText, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { serviceRequestsAPI, type ServiceRequest } from '$lib/api/crm';
import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<ServiceRequest[]>([]);
let loading = $state(false);
let search = $state('');
let statusFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
items.filter((r) => {
if (statusFilter && r.status !== statusFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''}`.toLowerCase().includes(q);
}
return true;
})
);
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await serviceRequestsAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes');
} finally {
loading = false;
}
}
async function remove(r: ServiceRequest) {
if (!companyId || !confirm(`¿Eliminar la solicitud ${r.reference ?? r.id}?`)) return;
try {
await serviceRequestsAPI.remove(r.id, companyId);
toast.success('Solicitud eliminada');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> Solicitudes de servicio</h1>
<p class="mt-1 text-sm text-muted-foreground">Levantamiento de requerimientos (RFQ) para cotizar.</p>
</div>
<Button href="/dashboard/crm/solicitudes/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nueva solicitud</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio o ruta…" bind:value={search} />
</div>
<select class={inputCls} bind:value={statusFilter}>
<option value="">Todos los estatus</option>
{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin solicitudes.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Operación</Table.Head>
<Table.Head>Medio</Table.Head>
<Table.Head>Ruta</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as r (r.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/solicitudes/${r.id}`}>{r.reference ?? `#${r.id}`}</a></Table.Cell>
<Table.Cell>{labelOf(OPERATION_TYPES, r.operation_type)}</Table.Cell>
<Table.Cell>{labelOf(TRANSPORT_MODES, r.transport_mode)}</Table.Cell>
<Table.Cell class="text-sm">{[r.origin, r.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell>
<Table.Cell>{labelOf(SR_STATUS, r.status)}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/crm/solicitudes/${r.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onclick={() => remove(r)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,213 @@
<script lang="ts">
import { ArrowLeft, FileText, Plus, Trash2 } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
serviceRequestsAPI, rateRequestsAPI, accountsAPI, suppliersAPI,
type ServiceRequest, type ServiceRequestInput, type RateRequest, type RateRequestInput,
type Account, type Supplier
} from '$lib/api/crm';
import {
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES, SR_STATUS,
QUOTE_CONCEPTS, RATE_STATUS, labelOf
} from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const srId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let sr = $state<ServiceRequest | null>(null);
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion' });
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let rates = $state<RateRequest[]>([]);
let tab = $state('requerimientos');
let loading = $state(false);
let saving = $state(false);
let busy = $state(false);
let adding = $state(false);
let newRate = $state<RateRequestInput>({ service_request_id: 0, concept: 'flete_internacional', status: 'solicitada' });
$effect(() => {
const cid = companyId;
const id = srId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
[sr, accounts, suppliers, rates] = await Promise.all([
serviceRequestsAPI.get(id, cid),
accountsAPI.list(cid),
suppliersAPI.list(cid),
rateRequestsAPI.list(cid, id)
]);
form = { ...sr };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la solicitud');
} finally {
loading = false;
}
}
async function save() {
if (!companyId || !sr) return;
saving = true;
try {
sr = await serviceRequestsAPI.update(sr.id, form, companyId);
form = { ...sr };
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function registerContact() {
if (!companyId || !sr) return;
const notes = window.prompt('Nota del contacto al cliente (opcional):') ?? null;
busy = true;
try {
sr = await serviceRequestsAPI.registerContact(sr.id, companyId, notes);
form = { ...sr };
toast.success('Contacto registrado');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo registrar el contacto');
} finally {
busy = false;
}
}
async function requote() {
if (!companyId || !sr) return;
busy = true;
try {
sr = await serviceRequestsAPI.requote(sr.id, companyId);
form = { ...sr };
toast.success('Solicitud reabierta para re-cotizar');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reabrir');
} finally {
busy = false;
}
}
function startAdd() {
newRate = { service_request_id: srId, concept: 'flete_internacional', status: 'solicitada', currency: 'USD' };
adding = true;
}
async function saveRate() {
if (!companyId) return;
try {
await rateRequestsAPI.create({ ...newRate, service_request_id: srId }, companyId);
toast.success('Tarifa agregada');
adding = false;
rates = await rateRequestsAPI.list(companyId, srId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo agregar');
}
}
async function removeRate(r: RateRequest) {
if (!companyId || !confirm('¿Eliminar tarifa?')) return;
await rateRequestsAPI.remove(r.id, companyId);
rates = await rateRequestsAPI.list(companyId, srId);
}
function supplierName(id: number | null): string {
return suppliers.find((s) => s.id === id)?.name ?? '—';
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/solicitudes"><ArrowLeft class="mr-1 h-4 w-4" /> Solicitudes</Button>
{#if loading && !sr}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if sr}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> {sr.reference ?? `Solicitud #${sr.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}</p>
</div>
<div class="flex flex-wrap gap-2">
{#if sr.status === 'nueva' || sr.status === 'contacto'}<Button size="sm" variant="outline" onclick={registerContact} disabled={busy}>Registrar contacto</Button>{/if}
{#if sr.status === 'rechazada' || sr.status === 'cotizada'}<Button size="sm" variant="outline" onclick={requote} disabled={busy}>Re-cotizar</Button>{/if}
</div>
</div>
{#if sr.first_contact_at}<p class="text-xs text-muted-foreground">Contacto registrado{#if sr.first_contact_notes}: {sr.first_contact_notes}{/if}</p>{/if}
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each [{ id: 'requerimientos', label: 'Requerimientos' }, { id: 'tarifas', label: 'Tarifas' }] as t (t.id)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'requerimientos'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class={inputCls} maxlength="10" bind:value={form.incoterm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de carga</span><input class={inputCls} bind:value={form.cargo_type} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor / Equipo</span><input class={inputCls} bind:value={form.container_equipment} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SR_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="2" class={inputCls} bind:value={form.requirements}></textarea></label>
</div>
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{:else}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startAdd}><Plus class="mr-1 h-4 w-4" /> Agregar tarifa</Button></div>
{#if adding}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newRate.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Proveedor</span><select class={inputCls} bind:value={newRate.supplier_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newRate.rate_amount} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={newRate.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={newRate.status}>{#each RATE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newRate.description} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (adding = false)}>Cancelar</Button><Button size="sm" onclick={saveRate}>Guardar</Button></div>
</div>
{/if}
{#if rates.length === 0}
<p class="text-sm text-muted-foreground">Sin solicitudes de tarifa.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Proveedor</Table.Head><Table.Head class="text-right">Tarifa</Table.Head><Table.Head>Estatus</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each rates as r (r.id)}
<Table.Row>
<Table.Cell>{labelOf(QUOTE_CONCEPTS, r.concept)}</Table.Cell>
<Table.Cell>{supplierName(r.supplier_id)}</Table.Cell>
<Table.Cell class="text-right">{r.rate_amount != null ? `${r.rate_amount} ${r.currency ?? ''}` : '—'}</Table.Cell>
<Table.Cell>{labelOf(RATE_STATUS, r.status)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeRate(r)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { ArrowLeft, FileText } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { serviceRequestsAPI, accountsAPI, suppliersAPI, type ServiceRequestInput, type Account, type Supplier } from '$lib/api/crm';
import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, LOAD_TYPES } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let form = $state<ServiceRequestInput>({ operation_type: 'exportacion', status: 'nueva' });
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
$effect(() => {
const cid = companyId;
if (!cid) return;
void (async () => {
[accounts, suppliers] = await Promise.all([accountsAPI.list(cid), suppliersAPI.list(cid)]);
})();
});
async function save() {
if (!companyId) return;
if (!form.operation_type) { toast.error('El tipo de operación es obligatorio'); return; }
saving = true;
try {
const created = await serviceRequestsAPI.create(form, companyId);
toast.success('Solicitud creada');
await goto(`/dashboard/crm/solicitudes/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear la solicitud');
} finally {
saving = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/solicitudes"><ArrowLeft class="mr-1 h-4 w-4" /> Solicitudes</Button>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> Nueva solicitud de servicio</h1>
<Card.Root>
<Card.Content class="space-y-5 pt-6">
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Generales</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de operación *</span><select class={inputCls} bind:value={form.operation_type}>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio de transporte</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Incoterm</span><input class={inputCls} maxlength="10" bind:value={form.incoterm} placeholder="FOB, CIF…" /></label>
</fieldset>
<fieldset class="grid gap-4 sm:grid-cols-2">
<legend class="mb-1 text-sm font-semibold text-muted-foreground">Logística y carga</legend>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de carga</span><input class={inputCls} bind:value={form.cargo_type} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modalidad</span><select class={inputCls} bind:value={form.load_type}><option value={undefined}>—</option>{#each LOAD_TYPES as l (l.value)}<option value={l.value}>{l.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso (kg)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.weight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor / Equipo</span><input class={inputCls} bind:value={form.container_equipment} placeholder="1x40'HC" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha requerida</span><input type="date" class={inputCls} bind:value={form.required_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Mercancía</span><textarea rows="2" class={inputCls} bind:value={form.commodity}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Otros requerimientos</span><textarea rows="2" class={inputCls} bind:value={form.requirements}></textarea></label>
</fieldset>
<div class="flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/crm/solicitudes">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { Receipt, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { invoicesAPI, type Invoice } from '$lib/api/fin';
import { INVOICE_STATUS, labelOf, formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<Invoice[]>([]);
let loading = $state(false);
let search = $state('');
let statusFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
items.filter((i) => {
if (statusFilter && i.status !== statusFilter) return false;
if (search.trim()) return `${i.reference ?? ''}`.toLowerCase().includes(search.trim().toLowerCase());
return true;
})
);
const statusClass: Record<string, string> = {
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
emitida: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
enviada: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
pagada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await invoicesAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las facturas');
} finally {
loading = false;
}
}
async function remove(i: Invoice) {
if (!companyId || !confirm(`¿Eliminar la factura ${i.reference ?? i.id}?`)) return;
try {
await invoicesAPI.remove(i.id, companyId);
toast.success('Factura eliminada');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Facturas y cobranza</h1>
<p class="mt-1 text-sm text-muted-foreground">Emisión de facturas y registro de pagos.</p>
</div>
<Button href="/dashboard/fin/facturas/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nueva factura</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio…" bind:value={search} />
</div>
<select class={inputCls} bind:value={statusFilter}>
<option value="">Todos los estatus</option>
{#each INVOICE_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin facturas.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Total</Table.Head>
<Table.Head class="text-right">Saldo</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as i (i.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/fin/facturas/${i.id}`}>{i.reference ?? `#${i.id}`}</a></Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[i.status] ?? ''}">{labelOf(INVOICE_STATUS, i.status)}</span></Table.Cell>
<Table.Cell class="text-right">{formatMoney(i.total, i.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(i.balance, i.currency)}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/fin/facturas/${i.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onclick={() => remove(i)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,280 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X, FileText, Check, ClipboardCheck } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import {
invoicesAPI, invoiceItemsAPI, paymentsAPI,
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
} from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const invoiceId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let invoice = $state<Invoice | null>(null);
let items = $state<InvoiceItem[]>([]);
let payments = $state<Payment[]>([]);
let accounts = $state<Account[]>([]);
let form = $state<InvoiceInput>({});
let tab = $state('conceptos');
let loading = $state(false);
let saving = $state(false);
let busy = $state(false);
let addingItem = $state(false);
let addingPay = $state(false);
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
$effect(() => {
const cid = companyId;
const id = invoiceId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
[invoice, items, payments, accounts] = await Promise.all([
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid)
]);
form = { ...invoice };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar la factura');
} finally {
loading = false;
}
}
async function reload() {
if (companyId) {
[invoice, items, payments] = await Promise.all([
invoicesAPI.get(invoiceId, companyId), invoicesAPI.items(invoiceId, companyId), invoicesAPI.payments(invoiceId, companyId)
]);
form = { ...invoice };
}
}
async function saveHeader() {
if (!companyId || !invoice) return;
saving = true;
try {
invoice = await invoicesAPI.update(invoice.id, form, companyId);
await reload();
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function doAction(action: 'emit' | 'send' | 'cancel') {
if (!companyId || !invoice) return;
busy = true;
try {
invoice = await invoicesAPI[action](invoice.id, companyId);
form = { ...invoice };
toast.success(action === 'send' ? 'Factura enviada (PDF generado)' : 'Factura actualizada');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
busy = false;
}
}
async function openPdf() {
if (!companyId || !invoice) return;
try {
const { url } = await invoicesAPI.pdfUrl(invoice.id, companyId);
window.open(url, '_blank', 'noopener');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el PDF');
}
}
async function markReview() {
if (!companyId || !invoice) return;
busy = true;
try {
invoice = await invoicesAPI.markClientReview(invoice.id, companyId);
form = { ...invoice };
toast.success('Factura en revisión del cliente');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
busy = false;
}
}
async function clientDecision(approved: boolean) {
if (!companyId || !invoice) return;
const notes = approved ? null : (window.prompt('Observaciones del cliente:') ?? null);
busy = true;
try {
invoice = await invoicesAPI.clientDecision(invoice.id, approved, companyId, notes);
form = { ...invoice };
toast.success(approved ? 'Aprobada por el cliente' : 'Registrada con observaciones');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo actualizar');
} finally {
busy = false;
}
}
function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; }
async function saveItem() {
if (!companyId) return;
try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo agregar'); }
}
async function removeItem(it: InvoiceItem) {
if (!companyId || !confirm('¿Eliminar concepto?')) return;
await invoiceItemsAPI.remove(it.id, companyId); await reload();
}
function startPay() { newPay = { invoice_id: invoiceId, amount: 0, method: 'transferencia' }; addingPay = true; }
async function savePay() {
if (!companyId) return;
try { await paymentsAPI.create({ ...newPay, invoice_id: invoiceId }, companyId); addingPay = false; await reload(); toast.success('Pago registrado'); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo registrar'); }
}
async function removePay(p: Payment) {
if (!companyId || !confirm('¿Eliminar pago?')) return;
await paymentsAPI.remove(p.id, companyId); await reload();
}
const statusClass: Record<string, string> = {
borrador: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
emitida: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
enviada: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
en_revision_cliente: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400',
pagada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/facturas"><ArrowLeft class="mr-1 h-4 w-4" /> Facturas</Button>
{#if loading && !invoice}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if invoice}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> {invoice.reference ?? `Factura #${invoice.id}`}</h1>
<p class="mt-1 text-sm"><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[invoice.status] ?? ''}">{labelOf(INVOICE_STATUS, invoice.status)}</span></p>
</div>
<div class="flex flex-wrap gap-2">
{#if invoice.status === 'borrador'}<Button size="sm" variant="outline" onclick={() => doAction('emit')} disabled={busy}><FileCheck class="mr-1 h-4 w-4" /> Emitir</Button>{/if}
{#if invoice.status === 'emitida'}<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar (PDF)</Button>{/if}
{#if invoice.pdf_file_key}<Button size="sm" variant="outline" onclick={openPdf}><FileText class="mr-1 h-4 w-4" /> Ver PDF</Button>{/if}
{#if invoice.status === 'enviada'}<Button size="sm" variant="outline" onclick={markReview} disabled={busy}><ClipboardCheck class="mr-1 h-4 w-4" /> En revisión</Button>{/if}
{#if invoice.status === 'enviada' || invoice.status === 'en_revision_cliente'}
<Button size="sm" variant="outline" onclick={() => clientDecision(true)} disabled={busy}><Check class="mr-1 h-4 w-4 text-emerald-600" /> Cliente aprueba</Button>
<Button size="sm" variant="outline" onclick={() => clientDecision(false)} disabled={busy}><X class="mr-1 h-4 w-4 text-destructive" /> Con observaciones</Button>
{/if}
{#if invoice.status !== 'cancelada' && invoice.status !== 'pagada'}<Button size="sm" variant="outline" onclick={() => doAction('cancel')} disabled={busy}><X class="mr-1 h-4 w-4" /> Cancelar</Button>{/if}
</div>
</div>
{#if invoice.client_reviewed_at}
<p class="text-xs text-muted-foreground">Revisión del cliente: {invoice.client_approved ? 'aprobada' : 'con observaciones'}{#if invoice.review_notes}{invoice.review_notes}{/if}</p>
{/if}
<div class="grid gap-4 sm:grid-cols-4">
<Card.Root><Card.Header><Card.Description>Subtotal</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.subtotal, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Impuesto ({invoice.tax_rate}%)</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.tax_amount, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Total</Card.Description><Card.Title class="text-lg">{formatMoney(invoice.total, invoice.currency)}</Card.Title></Card.Header></Card.Root>
<Card.Root><Card.Header><Card.Description>Saldo</Card.Description><Card.Title class="text-lg {invoice.balance > 0 ? 'text-amber-600' : 'text-emerald-600'}">{formatMoney(invoice.balance, invoice.currency)}</Card.Title></Card.Header></Card.Root>
</div>
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each [{ id: 'conceptos', label: 'Conceptos' }, { id: 'pagos', label: 'Pagos / cobranza' }, { id: 'datos', label: 'Datos' }] as t (t.id)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'conceptos'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startItem}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
{#if addingItem}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingItem = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
</div>
{/if}
{#if items.length === 0}
<p class="text-sm text-muted-foreground">Sin conceptos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Unitario</Table.Head><Table.Head class="text-right">Importe</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
{:else if tab === 'pagos'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startPay}><Plus class="mr-1 h-4 w-4" /> Registrar pago</Button></div>
{#if addingPay}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Monto</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newPay.amount} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha</span><input type="date" class={inputCls} bind:value={newPay.payment_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método</span><select class={inputCls} bind:value={newPay.method}>{#each PAYMENT_METHODS as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Referencia</span><input class={inputCls} bind:value={newPay.reference} /></label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingPay = false)}>Cancelar</Button><Button size="sm" onclick={savePay}>Guardar</Button></div>
</div>
{/if}
{#if payments.length === 0}
<p class="text-sm text-muted-foreground">Sin pagos registrados.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Fecha</Table.Head><Table.Head>Método</Table.Head><Table.Head>Referencia</Table.Head><Table.Head class="text-right">Monto</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each payments as p (p.id)}
<Table.Row>
<Table.Cell>{p.payment_date ?? '—'}</Table.Cell>
<Table.Cell>{labelOf(PAYMENT_METHODS, p.method)}</Table.Cell>
<Table.Cell class="font-mono text-xs">{p.reference ?? '—'}</Table.Cell>
<Table.Cell class="text-right">{formatMoney(p.amount, invoice.currency)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removePay(p)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
{:else}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Emisión</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
</div>
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={saveHeader} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { ArrowLeft, Receipt } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { invoicesAPI, type InvoiceInput } from '$lib/api/fin';
import { accountsAPI, type Account } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
let form = $state<InvoiceInput>({ currency: 'MXN', tax_rate: 16 });
let accounts = $state<Account[]>([]);
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
$effect(() => {
const cid = companyId;
if (!cid) return;
void (async () => { accounts = await accountsAPI.list(cid); })();
});
async function save() {
if (!companyId) return;
saving = true;
try {
const created = await invoicesAPI.create(form, companyId);
toast.success('Factura creada');
await goto(`/dashboard/fin/facturas/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear la factura');
} finally {
saving = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/fin/facturas"><ArrowLeft class="mr-1 h-4 w-4" /> Facturas</Button>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> Nueva factura</h1>
<p class="text-sm text-muted-foreground">Tip: también puedes generar la factura automáticamente desde un embarque (botón en el embarque).</p>
<Card.Root>
<Card.Content class="pt-6">
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="F-0001" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto (IVA)</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
</div>
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/fin/facturas">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear y agregar conceptos'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { Ship, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { shipmentsAPI, type Shipment } from '$lib/api/ops';
import { SHIPMENT_STATUS, OPERATION_TYPES, TRANSPORT_MODES, labelOf, formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let items = $state<Shipment[]>([]);
let loading = $state(false);
let search = $state('');
let statusFilter = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const filtered = $derived(
items.filter((s) => {
if (statusFilter && s.status !== statusFilter) return false;
if (search.trim()) {
const q = search.trim().toLowerCase();
return `${s.reference ?? ''} ${s.booking_number ?? ''} ${s.origin ?? ''} ${s.destination ?? ''}`.toLowerCase().includes(q);
}
return true;
})
);
const statusClass: Record<string, string> = {
abierta: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
booking: 'bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
en_transito: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400',
arribado: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-950/40 dark:text-cyan-400',
entregada: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400',
cerrada: 'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
cancelada: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400'
};
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
items = await shipmentsAPI.list(cid);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los embarques');
} finally {
loading = false;
}
}
async function remove(s: Shipment) {
if (!companyId || !confirm(`¿Eliminar el embarque ${s.reference ?? s.id}?`)) return;
try {
await shipmentsAPI.remove(s.id, companyId);
toast.success('Embarque eliminado');
await load(companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo eliminar');
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> Embarques</h1>
<p class="mt-1 text-sm text-muted-foreground">Operaciones logísticas: booking, Cut Off, documentos y seguimiento.</p>
</div>
<Button href="/dashboard/ops/embarques/nuevo" disabled={!companyId}><Plus class="mr-1 h-4 w-4" /> Nuevo embarque</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<div class="relative max-w-sm flex-1">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio, booking o ruta…" bind:value={search} />
</div>
<select class={inputCls} bind:value={statusFilter}>
<option value="">Todos los estatus</option>
{#each SHIPMENT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}
</select>
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin embarques.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Folio</Table.Head>
<Table.Head>Operación</Table.Head>
<Table.Head>Ruta</Table.Head>
<Table.Head>ETD / ETA</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head class="text-right">Acciones</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as s (s.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/ops/embarques/${s.id}`}>{s.reference ?? `#${s.id}`}</a>{#if s.booking_number}<span class="block text-xs text-muted-foreground">{s.booking_number}</span>{/if}</Table.Cell>
<Table.Cell class="text-sm">{labelOf(OPERATION_TYPES, s.operation_type)} · {labelOf(TRANSPORT_MODES, s.transport_mode)}</Table.Cell>
<Table.Cell class="text-sm">{[s.origin, s.destination].filter(Boolean).join(' → ') || '—'}</Table.Cell>
<Table.Cell class="text-sm">{formatDate(s.etd)} / {formatDate(s.eta)}</Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[s.status] ?? ''}">{labelOf(SHIPMENT_STATUS, s.status)}</span></Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/ops/embarques/${s.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,395 @@
<script lang="ts">
import { ArrowLeft, Ship, Plus, Trash2, Check, X, Receipt, ListChecks, Upload, Lock, CalendarClock, GitBranch } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, suppliersAPI, type Account, type Supplier } from '$lib/api/crm';
import {
shipmentsAPI, shipmentDocumentsAPI, shipmentEventsAPI,
type Shipment, type ShipmentInput, type ShipmentDocument, type ShipmentDocumentInput, type ShipmentEvent
} from '$lib/api/ops';
import { invoicesAPI } from '$lib/api/fin';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import {
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
DOC_KINDS, SHIPMENT_DOC_TYPES, EVENT_STATUS, labelOf, formatDate
} from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const shipmentId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let shipment = $state<Shipment | null>(null);
let form = $state<ShipmentInput>({});
let accounts = $state<Account[]>([]);
let suppliers = $state<Supplier[]>([]);
let docs = $state<ShipmentDocument[]>([]);
let events = $state<ShipmentEvent[]>([]);
let tab = $state('datos');
let loading = $state(false);
let saving = $state(false);
let busy = $state(false);
let addingDoc = $state(false);
let uploading = $state(false);
let newDoc = $state<ShipmentDocumentInput>({ shipment_id: 0, doc_kind: 'master', doc_type: 'MBL' });
let addingEvent = $state(false);
let newEvent = $state<{ title: string; notes?: string }>({ title: '' });
let showClose = $state(false);
let closeForm = $state<{ actual_cost_total: number | null; cost_currency: string; notes: string }>({ actual_cost_total: null, cost_currency: 'MXN', notes: '' });
let showReschedule = $state(false);
let rescheduleForm = $state<{ etd: string; cutoff_date: string; reason: string }>({ etd: '', cutoff_date: '', reason: '' });
const canInvoice = $derived(shipment?.status === 'cerrada');
const canClose = $derived(!!shipment && shipment.status !== 'cerrada' && shipment.status !== 'cancelada');
$effect(() => {
const cid = companyId;
const id = shipmentId;
if (!cid || !id) return;
void load(cid, id);
});
async function load(cid: number, id: number) {
loading = true;
try {
[shipment, accounts, suppliers, docs, events] = await Promise.all([
shipmentsAPI.get(id, cid), accountsAPI.list(cid), suppliersAPI.list(cid),
shipmentsAPI.documents(id, cid), shipmentsAPI.events(id, cid)
]);
form = { ...shipment };
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el embarque');
} finally {
loading = false;
}
}
async function save() {
if (!companyId || !shipment) return;
saving = true;
try {
shipment = await shipmentsAPI.update(shipment.id, form, companyId);
form = { ...shipment };
toast.success('Cambios guardados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar');
} finally {
saving = false;
}
}
async function generateInvoice() {
if (!companyId || !shipment) return;
if (!confirm('¿Generar la factura de este embarque?')) return;
busy = true;
try {
const inv = await invoicesAPI.fromShipment(shipment.id, companyId);
toast.success('Factura generada');
await goto(`/dashboard/fin/facturas/${inv.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo generar la factura');
} finally {
busy = false;
}
}
async function closeOperation() {
if (!companyId || !shipment) return;
if (closeForm.actual_cost_total == null || Number.isNaN(Number(closeForm.actual_cost_total))) {
toast.error('Captura el costo final de la operación');
return;
}
busy = true;
try {
shipment = await shipmentsAPI.close(
shipment.id,
{ actual_cost_total: Number(closeForm.actual_cost_total), cost_currency: closeForm.cost_currency, notes: closeForm.notes || null },
companyId
);
form = { ...shipment };
showClose = false;
toast.success('Cierre operativo registrado; el embarque ya puede facturarse');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo cerrar la operación');
} finally {
busy = false;
}
}
async function doReschedule() {
if (!companyId || !shipment) return;
busy = true;
try {
shipment = await shipmentsAPI.reschedule(
shipment.id,
{ etd: rescheduleForm.etd || null, cutoff_date: rescheduleForm.cutoff_date || null, reason: rescheduleForm.reason || null },
companyId
);
form = { ...shipment };
events = await shipmentsAPI.events(shipmentId, companyId);
showReschedule = false;
rescheduleForm = { etd: '', cutoff_date: '', reason: '' };
toast.success('Salida reprogramada');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reprogramar');
} finally {
busy = false;
}
}
// ----- Documentos -----
function startDoc() { newDoc = { shipment_id: shipmentId, doc_kind: 'master', doc_type: 'MBL' }; addingDoc = true; }
async function onFilePicked(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || !companyId) return;
uploading = true;
try {
const up = await uploadFile(file, companyId);
newDoc = { ...newDoc, file_key: up.file_key, file_url: up.file_url, number: newDoc.number };
if (!newDoc.number) newDoc.number = up.name;
toast.success('Archivo subido');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'No se pudo subir el archivo');
} finally {
uploading = false;
}
}
async function saveDoc() {
if (!companyId) return;
try {
await shipmentDocumentsAPI.create({ ...newDoc, shipment_id: shipmentId }, companyId);
addingDoc = false;
docs = await shipmentsAPI.documents(shipmentId, companyId);
toast.success('Documento agregado');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo agregar');
}
}
async function openDoc(d: ShipmentDocument) {
if (!companyId) return;
try {
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
if (url) window.open(url, '_blank', 'noopener');
else toast.error('El documento no tiene archivo');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
}
}
async function removeDoc(d: ShipmentDocument) {
if (!companyId || !confirm('¿Eliminar documento?')) return;
await shipmentDocumentsAPI.remove(d.id, companyId);
docs = await shipmentsAPI.documents(shipmentId, companyId);
}
// ----- Bitácora -----
async function seedEvents() {
if (!companyId) return;
busy = true;
try {
events = await shipmentsAPI.seedEvents(shipmentId, companyId);
toast.success('Hitos generados');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron generar los hitos');
} finally {
busy = false;
}
}
async function completeEvent(ev: ShipmentEvent) {
if (!companyId) return;
try {
await shipmentEventsAPI.complete(ev.id, companyId);
events = await shipmentsAPI.events(shipmentId, companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo completar el hito');
}
}
async function decideEvent(ev: ShipmentEvent, outcome: 'autorizado' | 'rechazado') {
if (!companyId) return;
let notes: string | null = null;
if (outcome === 'rechazado') notes = window.prompt('Motivo del rechazo (se abrirá un hito de corrección):') ?? null;
try {
await shipmentEventsAPI.decide(ev.id, outcome, companyId, notes);
events = await shipmentsAPI.events(shipmentId, companyId);
toast.success(outcome === 'autorizado' ? 'Decisión autorizada' : 'Rechazado; se generó el hito de corrección');
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo registrar la decisión');
}
}
async function saveEvent() {
if (!companyId || !newEvent.title.trim()) { toast.error('El título es obligatorio'); return; }
await shipmentEventsAPI.create({ shipment_id: shipmentId, title: newEvent.title, notes: newEvent.notes, position: events.length }, companyId);
newEvent = { title: '' };
addingEvent = false;
events = await shipmentsAPI.events(shipmentId, companyId);
}
async function removeEvent(ev: ShipmentEvent) {
if (!companyId || !confirm('¿Eliminar hito?')) return;
await shipmentEventsAPI.remove(ev.id, companyId);
events = await shipmentsAPI.events(shipmentId, companyId);
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/ops/embarques"><ArrowLeft class="mr-1 h-4 w-4" /> Embarques</Button>
{#if loading && !shipment}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if shipment}
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> {shipment.reference ?? `Embarque #${shipment.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(SHIPMENT_STATUS, shipment.status)}{#if shipment.booking_number} · Booking {shipment.booking_number}{/if}</p>
</div>
<div class="flex flex-wrap gap-2">
{#if canClose}
<Button size="sm" variant="outline" onclick={() => (showReschedule = !showReschedule)} disabled={busy}><CalendarClock class="mr-1 h-4 w-4" /> Reprogramar salida</Button>
<Button size="sm" onclick={() => (showClose = !showClose)} disabled={busy}><Lock class="mr-1 h-4 w-4" /> Cerrar operación</Button>
{/if}
{#if canInvoice}
<Button size="sm" variant="outline" onclick={generateInvoice} disabled={busy}><Receipt class="mr-1 h-4 w-4" /> Generar factura</Button>
{/if}
</div>
</div>
{#if showReschedule}
<Card.Root><Card.Content class="grid gap-3 pt-6 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nueva ETD</span><input type="date" class={inputCls} bind:value={rescheduleForm.etd} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nuevo Cut Off</span><input type="datetime-local" class={inputCls} bind:value={rescheduleForm.cutoff_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Motivo</span><input class={inputCls} bind:value={rescheduleForm.reason} placeholder="Cut Off no alcanzado" /></label>
<div class="flex justify-end gap-2 sm:col-span-3"><Button variant="outline" size="sm" onclick={() => (showReschedule = false)}>Cancelar</Button><Button size="sm" onclick={doReschedule} disabled={busy}>Reprogramar</Button></div>
</Card.Content></Card.Root>
{/if}
{#if showClose}
<Card.Root><Card.Content class="grid gap-3 pt-6 sm:grid-cols-3">
<p class="text-sm text-muted-foreground sm:col-span-3">El cierre operativo registra los costos finales y habilita la facturación. Requiere resolver todos los puntos de decisión de la bitácora.</p>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Costo final total</span><input type="number" step="0.01" min="0" class={inputCls} bind:value={closeForm.actual_cost_total} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} bind:value={closeForm.cost_currency} maxlength="3" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><input class={inputCls} bind:value={closeForm.notes} /></label>
<div class="flex justify-end gap-2 sm:col-span-3"><Button variant="outline" size="sm" onclick={() => (showClose = false)}>Cancelar</Button><Button size="sm" onclick={closeOperation} disabled={busy}><Lock class="mr-1 h-4 w-4" /> Confirmar cierre</Button></div>
</Card.Content></Card.Root>
{/if}
<Card.Root>
<Card.Content class="pt-6">
<div class="mb-5 flex flex-wrap gap-1 border-b">
{#each [{ id: 'datos', label: 'Datos del embarque' }, { id: 'documentos', label: 'Documentos' }, { id: 'bitacora', label: 'Bitácora' }] as t (t.id)}
<button type="button" class="border-b-2 px-3 py-2 text-sm font-medium transition-colors {tab === t.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (tab = t.id)}>{t.label}</button>
{/each}
</div>
{#if tab === 'datos'}
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Operación</span><select class={inputCls} bind:value={form.operation_type}><option value={undefined}>—</option>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SHIPMENT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">No. de Booking</span><input class={inputCls} bind:value={form.booking_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cut Off</span><input type="datetime-local" class={inputCls} bind:value={form.cutoff_date} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cita / ventana de recolección</span><input type="datetime-local" class={inputCls} bind:value={form.pickup_at} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">ETD (salida)</span><input type="date" class={inputCls} bind:value={form.etd} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">ETA (llegada)</span><input type="date" class={inputCls} bind:value={form.eta} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Buque / Vuelo</span><input class={inputCls} bind:value={form.vessel_flight} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Contenedor</span><input class={inputCls} bind:value={form.container_number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Naviera / Aerolínea / Transportista</span><select class={inputCls} bind:value={form.carrier_supplier_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Transportista terrestre</span><select class={inputCls} bind:value={form.ground_carrier_supplier_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente aduanal</span><select class={inputCls} bind:value={form.customs_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Agente en destino</span><select class={inputCls} bind:value={form.destination_agent_id}><option value={undefined}>—</option>{#each suppliers as s (s.id)}<option value={s.id}>{s.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
</div>
<div class="mt-6 flex justify-end border-t pt-4"><Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button></div>
{:else if tab === 'documentos'}
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startDoc}><Plus class="mr-1 h-4 w-4" /> Agregar documento</Button></div>
{#if addingDoc}
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Clase</span><select class={inputCls} bind:value={newDoc.doc_kind}>{#each DOC_KINDS as k (k.value)}<option value={k.value}>{k.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Documento</span><select class={inputCls} bind:value={newDoc.doc_type}>{#each SHIPMENT_DOC_TYPES as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Número</span><input class={inputCls} bind:value={newDoc.number} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha emisión</span><input type="date" class={inputCls} bind:value={newDoc.issue_date} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium flex items-center gap-1"><Upload class="h-3.5 w-3.5" /> Archivo {#if uploading}<span class="text-xs text-muted-foreground">(subiendo…)</span>{:else if newDoc.file_key}<span class="text-xs text-emerald-600">(cargado)</span>{/if}</span>
<input type="file" class={inputCls} onchange={onFilePicked} />
</label>
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingDoc = false)}>Cancelar</Button><Button size="sm" onclick={saveDoc} disabled={uploading}>Guardar</Button></div>
</div>
{/if}
{#if docs.length === 0}
<p class="text-sm text-muted-foreground">Sin documentos.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Clase</Table.Head><Table.Head>Documento</Table.Head><Table.Head>Número</Table.Head><Table.Head>Emisión</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each docs as d (d.id)}
<Table.Row>
<Table.Cell>{labelOf(DOC_KINDS, d.doc_kind)}</Table.Cell>
<Table.Cell class="font-medium">{labelOf(SHIPMENT_DOC_TYPES, d.doc_type)}</Table.Cell>
<Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell>
<Table.Cell>{formatDate(d.issue_date)}</Table.Cell>
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}{/if}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDoc(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
{:else}
<div class="mb-3 flex flex-wrap justify-end gap-2">
{#if events.length === 0}<Button size="sm" variant="outline" onclick={seedEvents} disabled={busy}><ListChecks class="mr-1 h-4 w-4" /> Generar hitos</Button>{/if}
<Button size="sm" variant="outline" onclick={() => (addingEvent = true)}><Plus class="mr-1 h-4 w-4" /> Agregar hito</Button>
</div>
{#if addingEvent}
<div class="mb-4 grid gap-3 rounded-md border p-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Título del hito</span><input class={inputCls} bind:value={newEvent.title} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><input class={inputCls} bind:value={newEvent.notes} /></label>
<div class="flex justify-end gap-2"><Button variant="outline" size="sm" onclick={() => (addingEvent = false)}>Cancelar</Button><Button size="sm" onclick={saveEvent}>Guardar</Button></div>
</div>
{/if}
{#if events.length === 0}
<p class="text-sm text-muted-foreground">Sin hitos. Usa "Generar hitos" para crear la secuencia según el tipo de operación.</p>
{:else}
<ol class="space-y-2">
{#each events as ev (ev.id)}
<li class="flex items-center gap-3 rounded-md border p-3 {ev.status === 'rechazado' ? 'border-destructive/40' : ''}">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs {ev.status === 'completado' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : ev.status === 'rechazado' ? 'bg-destructive/10 text-destructive' : ev.kind === 'decision' ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400' : 'bg-muted text-muted-foreground'}">
{#if ev.status === 'completado'}{:else if ev.kind === 'decision'}?{:else}{ev.position + 1}{/if}
</span>
<div class="flex-1">
<p class="text-sm font-medium {ev.status === 'completado' ? 'line-through text-muted-foreground' : ''}">
{ev.title}
{#if ev.kind === 'decision'}<span class="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-700 dark:bg-amber-950/40 dark:text-amber-400">decisión</span>{/if}
{#if ev.parent_event_id}<span class="ml-1 inline-flex items-center gap-0.5 rounded bg-muted px-1.5 py-0.5 text-[10px] font-semibold uppercase text-muted-foreground"><GitBranch class="h-2.5 w-2.5" /> corrección · intento {ev.attempt}</span>{/if}
</p>
<p class="text-xs text-muted-foreground">
{labelOf(EVENT_STATUS, ev.status)}{#if ev.outcome}{ev.outcome}{/if}{#if ev.actual_date} · {formatDate(ev.actual_date)}{/if}
</p>
{#if ev.notes}<p class="text-xs text-muted-foreground">{ev.notes}</p>{/if}
</div>
{#if ev.kind === 'decision' && !ev.outcome}
<Button variant="ghost" size="sm" onclick={() => decideEvent(ev, 'autorizado')} aria-label="Autorizar"><Check class="h-4 w-4 text-emerald-600" /></Button>
<Button variant="ghost" size="sm" onclick={() => decideEvent(ev, 'rechazado')} aria-label="Rechazar"><X class="h-4 w-4 text-destructive" /></Button>
{:else if ev.kind !== 'decision' && ev.status !== 'completado'}
<Button variant="ghost" size="sm" onclick={() => completeEvent(ev)} aria-label="Completar"><Check class="h-4 w-4 text-emerald-600" /></Button>
{/if}
<Button variant="ghost" size="sm" onclick={() => removeEvent(ev)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
</li>
{/each}
</ol>
{/if}
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { ArrowLeft, Ship } from '@lucide/svelte';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { accountsAPI, type Account } from '$lib/api/crm';
import { shipmentsAPI, type ShipmentInput } from '$lib/api/ops';
import { OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
let form = $state<ShipmentInput>({ status: 'abierta' });
let accounts = $state<Account[]>([]);
let saving = $state(false);
const companyId = $derived(companyStore.activeCompany?.id ?? null);
$effect(() => {
const cid = companyId;
if (!cid) return;
void (async () => { accounts = await accountsAPI.list(cid); })();
});
async function save() {
if (!companyId) return;
saving = true;
try {
const created = await shipmentsAPI.create(form, companyId);
toast.success('Embarque creado');
await goto(`/dashboard/ops/embarques/${created.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear el embarque');
} finally {
saving = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/ops/embarques"><ArrowLeft class="mr-1 h-4 w-4" /> Embarques</Button>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Ship class="h-6 w-6" /> Nuevo embarque</h1>
<Card.Root>
<Card.Content class="pt-6">
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Folio</span><input class={inputCls} bind:value={form.reference} placeholder="EMB-0001" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cliente</span><select class={inputCls} bind:value={form.account_id}><option value={undefined}>—</option>{#each accounts as a (a.id)}<option value={a.id}>{a.name}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Operación</span><select class={inputCls} bind:value={form.operation_type}><option value={undefined}>—</option>{#each OPERATION_TYPES as o (o.value)}<option value={o.value}>{o.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Medio</span><select class={inputCls} bind:value={form.transport_mode}><option value={undefined}>—</option>{#each TRANSPORT_MODES as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de servicio</span><select class={inputCls} bind:value={form.service_type}><option value={undefined}>—</option>{#each SERVICE_TYPES as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each SHIPMENT_STATUS as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={form.origin} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino</span><input class={inputCls} bind:value={form.destination} /></label>
</div>
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" href="/dashboard/ops/embarques">Cancelar</Button>
<Button onclick={save} disabled={saving || !companyId}>{saving ? 'Guardando…' : 'Crear'}</Button>
</div>
</Card.Content>
</Card.Root>
</div>