Compare commits
15 Commits
b12af1a561
...
docs/chang
| Author | SHA1 | Date | |
|---|---|---|---|
| 59fb371f5d | |||
|
|
c6f18013b3 | ||
|
|
f03ac38c4f | ||
|
|
5a250204b5 | ||
|
|
c983c744ac | ||
|
|
e806512a89 | ||
|
|
3ea8d5f3ef | ||
|
|
e79705e6e3 | ||
|
|
116d2e7f5a | ||
|
|
e724aeae50 | ||
|
|
0b12ad5354 | ||
|
|
6f200b4505 | ||
|
|
a196c44fae | ||
|
|
c26e04bcff | ||
|
|
79135d9b5a |
90
CHANGELOG.md
Normal file
90
CHANGELOG.md
Normal 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.
|
||||
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal file
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal 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")
|
||||
@@ -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")
|
||||
92
backend/alembic/versions/d5e6f7a8b9c0_pdf_compliance.py
Normal file
92
backend/alembic/versions/d5e6f7a8b9c0_pdf_compliance.py
Normal 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")
|
||||
@@ -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}",
|
||||
|
||||
27
backend/api/v1/modules/core/permissions/seed_v2.py
Normal file
27
backend/api/v1/modules/core/permissions/seed_v2.py
Normal 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()
|
||||
0
backend/api/v1/modules/crm/catalogs/__init__.py
Normal file
0
backend/api/v1/modules/crm/catalogs/__init__.py
Normal file
37
backend/api/v1/modules/crm/catalogs/data.py
Normal file
37
backend/api/v1/modules/crm/catalogs/data.py
Normal 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"},
|
||||
]
|
||||
91
backend/api/v1/modules/crm/catalogs/routes.py
Normal file
91
backend/api/v1/modules/crm/catalogs/routes.py
Normal 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
|
||||
@@ -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"),
|
||||
|
||||
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
102
backend/api/v1/modules/crm/quotes/dto.py
Normal file
102
backend/api/v1/modules/crm/quotes/dto.py
Normal 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))
|
||||
60
backend/api/v1/modules/crm/quotes/models.py
Normal file
60
backend/api/v1/modules/crm/quotes/models.py
Normal 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)
|
||||
162
backend/api/v1/modules/crm/quotes/routes.py
Normal file
162
backend/api/v1/modules/crm/quotes/routes.py
Normal 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)
|
||||
281
backend/api/v1/modules/crm/quotes/service.py
Normal file
281
backend/api/v1/modules/crm/quotes/service.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
123
backend/api/v1/modules/crm/service_requests/dto.py
Normal file
123
backend/api/v1/modules/crm/service_requests/dto.py
Normal 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
|
||||
82
backend/api/v1/modules/crm/service_requests/models.py
Normal file
82
backend/api/v1/modules/crm/service_requests/models.py
Normal 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)
|
||||
169
backend/api/v1/modules/crm/service_requests/routes.py
Normal file
169
backend/api/v1/modules/crm/service_requests/routes.py
Normal 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)
|
||||
270
backend/api/v1/modules/crm/service_requests/service.py
Normal file
270
backend/api/v1/modules/crm/service_requests/service.py
Normal 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()
|
||||
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
0
backend/api/v1/modules/crm/uploads/__init__.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal file
63
backend/api/v1/modules/crm/uploads/routes.py
Normal 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)}
|
||||
0
backend/api/v1/modules/fin/__init__.py
Normal file
0
backend/api/v1/modules/fin/__init__.py
Normal file
0
backend/api/v1/modules/fin/invoices/__init__.py
Normal file
0
backend/api/v1/modules/fin/invoices/__init__.py
Normal file
121
backend/api/v1/modules/fin/invoices/dto.py
Normal file
121
backend/api/v1/modules/fin/invoices/dto.py
Normal 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
|
||||
86
backend/api/v1/modules/fin/invoices/models.py
Normal file
86
backend/api/v1/modules/fin/invoices/models.py
Normal 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)
|
||||
186
backend/api/v1/modules/fin/invoices/pdf.py
Normal file
186
backend/api/v1/modules/fin/invoices/pdf.py
Normal 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)
|
||||
134
backend/api/v1/modules/fin/invoices/routes.py
Normal file
134
backend/api/v1/modules/fin/invoices/routes.py
Normal 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)
|
||||
397
backend/api/v1/modules/fin/invoices/service.py
Normal file
397
backend/api/v1/modules/fin/invoices/service.py
Normal 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()
|
||||
17
backend/api/v1/modules/fin/permissions.py
Normal file
17
backend/api/v1/modules/fin/permissions.py
Normal 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()
|
||||
12
backend/api/v1/modules/fin/router.py
Normal file
12
backend/api/v1/modules/fin/router.py
Normal 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)
|
||||
0
backend/api/v1/modules/ops/__init__.py
Normal file
0
backend/api/v1/modules/ops/__init__.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal 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()
|
||||
16
backend/api/v1/modules/ops/router.py
Normal file
16
backend/api/v1/modules/ops/router.py
Normal 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)
|
||||
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
173
backend/api/v1/modules/ops/shipments/dto.py
Normal file
173
backend/api/v1/modules/ops/shipments/dto.py
Normal 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
|
||||
112
backend/api/v1/modules/ops/shipments/models.py
Normal file
112
backend/api/v1/modules/ops/shipments/models.py
Normal 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)
|
||||
240
backend/api/v1/modules/ops/shipments/routes.py
Normal file
240
backend/api/v1/modules/ops/shipments/routes.py
Normal 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)
|
||||
524
backend/api/v1/modules/ops/shipments/service.py
Normal file
524
backend/api/v1/modules/ops/shipments/service.py
Normal 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
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
169
backend/tests/test_compliance.py
Normal file
169
backend/tests/test_compliance.py
Normal 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"
|
||||
57
backend/tests/test_invoices.py
Normal file
57
backend/tests/test_invoices.py
Normal 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
|
||||
46
backend/tests/test_quotes.py
Normal file
46
backend/tests/test_quotes.py
Normal 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"
|
||||
66
backend/tests/test_service_requests.py
Normal file
66
backend/tests/test_service_requests.py
Normal 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"
|
||||
34
backend/tests/test_shipment_events.py
Normal file
34
backend/tests/test_shipment_events.py
Normal 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
|
||||
60
backend/tests/test_shipments.py
Normal file
60
backend/tests/test_shipments.py
Normal 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
84
deploy/README.md
Normal 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.
|
||||
17
deploy/docker-compose.preview.yml
Normal file
17
deploy/docker-compose.preview.yml
Normal 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 []
|
||||
33
deploy/docker-compose.testing.yml
Normal file
33
deploy/docker-compose.testing.yml
Normal 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 []
|
||||
48
deploy/env.testing.example
Normal file
48
deploy/env.testing.example
Normal 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
|
||||
81
deploy/nginx/testing.crm.aduanasoft.com.conf
Normal file
81
deploy/nginx/testing.crm.aduanasoft.com.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
1470
docs/planes/T2026-08-046_prompt.md
Normal file
1470
docs/planes/T2026-08-046_prompt.md
Normal file
File diff suppressed because it is too large
Load Diff
36
frontend/src/lib/api/crm/addresses.ts
Normal file
36
frontend/src/lib/api/crm/addresses.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
179
frontend/src/lib/api/crm/commercial.ts
Normal file
179
frontend/src/lib/api/crm/commercial.ts
Normal 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)}`))
|
||||
};
|
||||
36
frontend/src/lib/api/crm/documents.ts
Normal file
36
frontend/src/lib/api/crm/documents.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
39
frontend/src/lib/api/crm/suppliers.ts
Normal file
39
frontend/src/lib/api/crm/suppliers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
116
frontend/src/lib/api/fin/index.ts
Normal file
116
frontend/src/lib/api/fin/index.ts
Normal 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)}`))
|
||||
};
|
||||
138
frontend/src/lib/api/ops/index.ts
Normal file
138
frontend/src/lib/api/ops/index.ts
Normal 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)}`))
|
||||
};
|
||||
33
frontend/src/lib/api/uploads.ts
Normal file
33
frontend/src/lib/api/uploads.ts
Normal 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;
|
||||
}
|
||||
53
frontend/src/lib/components/crm/AccountFields.svelte
Normal file
53
frontend/src/lib/components/crm/AccountFields.svelte
Normal 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}
|
||||
303
frontend/src/lib/components/crm/RelatedManager.svelte
Normal file
303
frontend/src/lib/components/crm/RelatedManager.svelte
Normal 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}
|
||||
71
frontend/src/lib/components/crm/SupplierFields.svelte
Normal file
71
frontend/src/lib/components/crm/SupplierFields.svelte
Normal 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}
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
|
||||
121
frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte
Normal file
121
frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte
Normal 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>
|
||||
251
frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte
Normal file
251
frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
117
frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte
Normal file
117
frontend/src/routes/dashboard/crm/cuentas/[id]/+page.svelte
Normal 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>
|
||||
76
frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte
Normal file
76
frontend/src/routes/dashboard/crm/cuentas/nuevo/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
124
frontend/src/routes/dashboard/crm/proveedores/+page.svelte
Normal file
124
frontend/src/routes/dashboard/crm/proveedores/+page.svelte
Normal 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>
|
||||
136
frontend/src/routes/dashboard/crm/proveedores/[id]/+page.svelte
Normal file
136
frontend/src/routes/dashboard/crm/proveedores/[id]/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
119
frontend/src/routes/dashboard/crm/solicitudes/+page.svelte
Normal file
119
frontend/src/routes/dashboard/crm/solicitudes/+page.svelte
Normal 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>
|
||||
213
frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte
Normal file
213
frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
121
frontend/src/routes/dashboard/fin/facturas/+page.svelte
Normal file
121
frontend/src/routes/dashboard/fin/facturas/+page.svelte
Normal 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>
|
||||
280
frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte
Normal file
280
frontend/src/routes/dashboard/fin/facturas/[id]/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
129
frontend/src/routes/dashboard/ops/embarques/+page.svelte
Normal file
129
frontend/src/routes/dashboard/ops/embarques/+page.svelte
Normal 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>
|
||||
395
frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte
Normal file
395
frontend/src/routes/dashboard/ops/embarques/[id]/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user