11 Commits

Author SHA1 Message Date
Ernesto Herrera
87d23b3d23 feat(crm): rediseño profesional del PDF de cotización
Banda de encabezado, logo + emisor, título con regla, panel de datos, barras de
sección en color, tabla de costos con encabezado y filas alternadas, caja de
totales, y pie con banda. Fix: elipsis "…" -> "..." (evita "?" en latin-1);
oculta secciones sin datos; color por defecto navy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 11:06:16 -06:00
Ernesto Herrera
e269e46d88 fix(crm): envío de cotización por correo con aiosmtplib.send (sin doble STARTTLS)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:23 -06:00
Ernesto Herrera
03055cd377 fix(crm): servir el PDF de cotización por el backend (no exponer MinIO)
La URL prefirmada usaba el host interno http://minio:9000 (no accesible desde el
navegador). Se agrega GET /quotes/{id}/pdf que devuelve el PDF por el backend
(vía nginx) y el visor usa un blob autenticado (api.getBlob).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 10:33:14 -06:00
Ernesto Herrera
e09cb02b8b feat(crm): cotización — Ver PDF, Enviar por correo y pantalla de marca por tenant
- Cliente API: quotesAPI.pdfUrl/sendEmail + quoteSettingsAPI (get/save/uploadLogo).
- Cotización: botones Ver PDF y Enviar por correo (modal con destinatario/asunto/mensaje).
- Configuración → Formato de cotización: emisor, logo, color, prefijo y textos por
  defecto (por compañía).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:22:24 -06:00
Ernesto Herrera
206ff450f8 feat(crm): PDF de cotización (formato maestro) + marca por tenant + envío por correo
- Modelo crm.quote_settings (emisor, logo, color, prefijo, términos) por compañía
  + columna quotes.pdf_file_key + migración con down().
- Generador PDF (formato maestro: emisor+logo, cliente, carga/ruta, costos,
  resumen, condiciones); logo incrustado como JPEG (Pillow).
- Endpoints: GET /quotes/{id}/pdf-url, POST /quotes/{id}/send-email (adjunta PDF),
  GET/PUT /quote-settings, POST /quote-settings/logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 13:18:07 -06:00
Ernesto Herrera
ef7e69ed57 feat(crm): tarifario — editor de cargos adicionales + alta manual de rutas
- Backend: CRUD de cargos (rate_charges) por tarifario.
- Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres
  para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 09:46:25 -06:00
Ernesto Herrera
fa542ddf18 feat(crm): frontend del módulo Tarifario — tarifarios, import Excel y Cotizador
- Cliente API rateSheetsAPI (CRUD, plantilla, import con vista previa, /rate-quote).
- Tarifarios: listado + importar por Excel (plantilla, vista previa, validación) +
  detalle con rutas y activar/vencer.
- Cotizador: calcula opciones de costo por proveedor desde los tarifarios vigentes.
- Nav CRM: Tarifarios y Cotizador.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:59 -06:00
40 changed files with 4150 additions and 2195 deletions

View File

@@ -0,0 +1,60 @@
"""crm quote_settings (marca por tenant) + quotes.pdf_file_key
Revision ID: a0b1c2d3e4f5
Revises: f8a9b0c1d2e3
Create Date: 2026-07-29 00:00:00.000000
PDF de cotización con formato maestro + branding por tenant + envío por correo.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "a0b1c2d3e4f5"
down_revision: Union[str, None] = "f8a9b0c1d2e3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCHEMA = "crm"
def upgrade() -> None:
op.add_column("quotes", sa.Column("pdf_file_key", sa.String(length=512), nullable=True), schema=SCHEMA)
op.create_table(
"quote_settings",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("emitter_name", sa.String(length=255), nullable=True),
sa.Column("emitter_rfc", sa.String(length=13), nullable=True),
sa.Column("emitter_address", sa.Text(), nullable=True),
sa.Column("emitter_phone", sa.String(length=60), nullable=True),
sa.Column("emitter_email", sa.String(length=255), nullable=True),
sa.Column("emitter_website", sa.String(length=255), nullable=True),
sa.Column("logo_file_key", sa.String(length=512), nullable=True),
sa.Column("accent_color", sa.String(length=9), nullable=True, server_default=sa.text("'#2f6bf0'")),
sa.Column("quote_prefix", sa.String(length=12), nullable=True, server_default=sa.text("'COT'")),
sa.Column("default_terms", sa.Text(), nullable=True),
sa.Column("footer_note", sa.Text(), nullable=True),
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
schema=SCHEMA,
)
op.create_index("ix_crm_quote_settings_id", "quote_settings", ["id"], schema=SCHEMA)
op.create_index("ix_crm_quote_settings_tenant_id", "quote_settings", ["tenant_id"], schema=SCHEMA)
op.create_index("ix_crm_quote_settings_company_id", "quote_settings", ["company_id"], schema=SCHEMA)
# Una configuración por compañía
op.create_index(
"uq_crm_quote_settings_company", "quote_settings", ["tenant_id", "company_id"],
unique=True, schema=SCHEMA, postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_table("quote_settings", schema=SCHEMA)
op.drop_column("quotes", "pdf_file_key", schema=SCHEMA)

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ class CatalogItemResponse(CatalogItemBase):
catalog: str
tenant_id: int | None
is_system: bool
extra: dict | None = None # metadata (ej. dimensiones de un tipo de equipo)
class CatalogMeta(BaseModel):

View File

@@ -38,6 +38,7 @@ def seed_global_catalogs(db: Session) -> dict:
label=item["label"],
parent_catalog=item.get("parent_catalog"),
parent_code=item.get("parent_code"),
extra=item.get("extra"),
tenant_id=None,
sort_order=order,
is_active=True,

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,7 @@ class QuoteResponse(QuoteBase):
status: str
total_cost: Decimal
total_sale: Decimal
pdf_file_key: str | None = None
sent_at: datetime | None = None
accepted_at: datetime | None = None
rejected_at: datetime | None = None
@@ -100,3 +101,30 @@ class QuoteResponse(QuoteBase):
@property
def margin(self) -> Decimal:
return (self.total_sale or Decimal(0)) - (self.total_cost or Decimal(0))
# ----- Configuración de marca del formato de cotización -----
class QuoteSettingsInput(BaseModel):
emitter_name: str | None = Field(None, max_length=255)
emitter_rfc: str | None = Field(None, max_length=13)
emitter_address: str | None = None
emitter_phone: str | None = Field(None, max_length=60)
emitter_email: str | None = Field(None, max_length=255)
emitter_website: str | None = Field(None, max_length=255)
accent_color: str | None = Field(None, max_length=9)
quote_prefix: str | None = Field(None, max_length=12)
default_terms: str | None = None
footer_note: str | None = None
class QuoteSettingsResponse(QuoteSettingsInput):
model_config = ConfigDict(from_attributes=True)
id: int | None = None
logo_file_key: str | None = None
class SendQuoteEmailRequest(BaseModel):
to: str | None = None
subject: str | None = None
message: str | None = None

View File

@@ -34,10 +34,35 @@ class Quote(Base, TenantScopedMixin, TimestampMixin):
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# Clave del PDF generado en MinIO (para regenerar/enviar)
pdf_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class QuoteSettings(Base, TenantScopedMixin, TimestampMixin):
"""Configuración de marca del formato de cotización, por compañía (tenant).
Encabezado del emisor, logo y textos por defecto que se imprimen en el PDF.
"""
__tablename__ = "quote_settings"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
emitter_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
emitter_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True)
emitter_address: Mapped[str | None] = mapped_column(Text, nullable=True)
emitter_phone: Mapped[str | None] = mapped_column(String(60), nullable=True)
emitter_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
emitter_website: Mapped[str | None] = mapped_column(String(255), nullable=True)
logo_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
accent_color: Mapped[str | None] = mapped_column(String(9), nullable=True, server_default=text("'#2f6bf0'"))
quote_prefix: Mapped[str | None] = mapped_column(String(12), nullable=True, server_default=text("'COT'"))
default_terms: Mapped[str | None] = mapped_column(Text, nullable=True)
footer_note: Mapped[str | None] = mapped_column(Text, nullable=True)
class QuoteItem(Base, TenantScopedMixin, TimestampMixin):
"""Concepto de una cotización (flete, transporte terrestre, despacho, gastos destino, otros)."""

View File

@@ -0,0 +1,376 @@
"""Generador del PDF de Cotización — diseño profesional, sin dependencias de sistema.
Compone un PDF 1.4 byte a byte (Helvetica / Helvetica-Bold) con barras de sección,
tabla de costos con bordes y filas alternadas, caja de totales y logo incrustado
(JPEG /DCTDecode vía Pillow). El branding (emisor, color) viene de la config por tenant.
"""
from __future__ import annotations
import io
from decimal import Decimal
_W = 612
_H = 792
_ML = 50 # margen izquierdo
_MR = 562 # margen derecho (x)
CONCEPT_LABELS = {
"flete_internacional": "Flete internacional",
"transporte_terrestre": "Transporte terrestre",
"despacho_aduanal": "Despacho aduanal",
"gastos_destino": "Gastos en destino",
"otros": "Otros cargos",
}
_TRANSLATE = str.maketrans({"": "-", "": "-", "": '"', "": '"', "": "'", "": "'", "": "-", "": "...", "\t": " "})
def _esc(text) -> str:
s = ("" if text is None else str(text)).translate(_TRANSLATE)
s = s.encode("latin-1", "replace").decode("latin-1")
return s.replace("\\", r"\\").replace("(", r"\(").replace(")", r"\)")
def _money(value) -> str:
return f"{Decimal(str(value or 0)).quantize(Decimal('0.01')):,.2f}"
def _num(value) -> str:
return f"{Decimal(str(value or 0)):,.2f}"
# Ancho aprox de una cadena en Helvetica (para alinear a la derecha / truncar)
def _text_w(s: str, size: float, bold: bool = False) -> float:
return len(s) * size * (0.56 if bold else 0.52)
def _fit(s: str, size: float, max_w: float) -> str:
s = s or ""
if _text_w(s, size) <= max_w:
return s
while s and _text_w(s + "", size) > max_w:
s = s[:-1]
return s + ""
def _wrap(text: str, width_chars: int) -> list[str]:
words = (text or "").split()
if not words:
return []
out, cur = [], ""
for w in words:
cand = f"{cur} {w}".strip()
if len(cand) > width_chars and cur:
out.append(cur)
cur = w
else:
cur = cand
if cur:
out.append(cur)
return out
def _hex_rgb(hexs: str | None) -> tuple[float, float, float]:
try:
h = (hexs or "#12294c").lstrip("#")
return tuple(int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) # type: ignore[return-value]
except Exception:
return (0.07, 0.16, 0.30)
def _prep_logo(logo_bytes: bytes | None):
if not logo_bytes:
return None
try:
from PIL import Image
im = Image.open(io.BytesIO(logo_bytes)).convert("RGB")
im.thumbnail((600, 300))
buf = io.BytesIO()
im.save(buf, format="JPEG", quality=88)
return buf.getvalue(), im.width, im.height
except Exception:
return None
class _Canvas:
"""Acumula operadores de contenido con paginación simple."""
def __init__(self):
self.pages: list[list[str]] = [[]]
self.y = _H
@property
def ops(self) -> list[str]:
return self.pages[-1]
def new_page(self):
self.pages.append([])
self.y = _H - 50
def ensure(self, needed: float):
if self.y - needed < 50:
self.new_page()
def rect(self, x, y, w, h, rgb):
r, g, b = rgb
self.ops.append(f"{r:.3f} {g:.3f} {b:.3f} rg {x:.1f} {y:.1f} {w:.1f} {h:.1f} re f")
def line(self, x1, y1, x2, y2, rgb, width=0.6):
r, g, b = rgb
self.ops.append(f"{width} w {r:.3f} {g:.3f} {b:.3f} RG {x1:.1f} {y1:.1f} m {x2:.1f} {y2:.1f} l S")
def text(self, x, y, s, size=10, rgb=(0, 0, 0), bold=False, right=False):
font = "F2" if bold else "F1"
r, g, b = rgb
tx = x - _text_w(str(s), size, bold) if right else x
self.ops.append(f"BT /{font} {size} Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {tx:.1f} {y:.1f} Tm ({_esc(s)}) Tj ET")
def build_quote_pdf(
*,
emitter: dict,
head: dict,
client: dict,
cargo: list[tuple[str, str]],
route: list[tuple[str, str]],
items: list[dict],
currency: str,
subtotal,
terms: str | None,
footer: str | None,
logo_bytes: bytes | None = None,
accent: str | None = "#12294c",
) -> bytes:
ACC = _hex_rgb(accent)
INK = (0.10, 0.15, 0.24)
GRAY = (0.42, 0.47, 0.55)
LINE = (0.80, 0.84, 0.90)
ZEBRA = (0.955, 0.965, 0.980)
logo = _prep_logo(logo_bytes)
c = _Canvas()
# ---------------- Encabezado ----------------
c.rect(0, _H - 12, _W, 12, ACC) # banda superior
logo_bottom = _H - 95
if logo:
_, lw, lh = logo
dw, dh = 150.0, 150.0 * lh / lw
if dh > 55:
dh, dw = 55.0, 55.0 * lw / lh
c.ops.append(f"q {dw:.1f} 0 0 {dh:.1f} {_ML} {logo_bottom:.1f} cm /Im0 Do Q")
else:
c.text(_ML, _H - 55, emitter.get("name") or "Emisor", 16, INK, bold=True)
# Emisor (derecha)
ex, ey = 320, _H - 42
c.text(ex, ey, emitter.get("name") or "Emisor", 12, INK, bold=True)
ey -= 14
em_lines = []
if emitter.get("rfc"):
em_lines.append(f"RFC: {emitter['rfc']}")
for a in (emitter.get("address") or "").splitlines():
if a.strip():
em_lines.append(a.strip())
contact = " ".join([x for x in [emitter.get("phone"), emitter.get("email"), emitter.get("website")] if x])
if contact:
em_lines.append(contact)
for ln in em_lines[:5]:
c.text(ex, ey, _fit(ln, 8.5, _MR - ex), 8.5, GRAY)
ey -= 11
# Título + regla
c.text(_ML, _H - 150, "COTIZACIÓN", 26, INK, bold=True)
c.line(_ML, _H - 158, _ML + 190, _H - 158, ACC, 2)
# Panel de datos (derecha)
px, pw = 320, _MR - 320
py_top = _H - 128
ph = 74
c.rect(px, py_top - ph, pw, ph, ZEBRA)
c.line(px, py_top, px, py_top - ph, LINE)
hy = py_top - 15
info = [
("No.", head.get("reference") or "-"),
("Fecha", head.get("issue_date") or "-"),
("Vigencia", head.get("valid_until") or "-"),
("Ejecutivo", head.get("owner") or "-"),
("Estatus", str(head.get("status") or "-").capitalize()),
]
for k, v in info:
c.text(px + 10, hy, f"{k}:", 8.5, GRAY, bold=True)
c.text(px + 66, hy, _fit(str(v), 9, pw - 76), 9, INK)
hy -= 12.5
c.y = _H - 215
# ---------------- Helpers de sección ----------------
def section(title: str):
c.ensure(30)
c.rect(_ML, c.y - 18, _MR - _ML, 18, ACC)
c.text(_ML + 8, c.y - 13, title.upper(), 9.5, (1, 1, 1), bold=True)
c.y -= 26
def kv_block(pairs: list[tuple[str, str]]):
rows = [(k, v) for k, v in pairs if v not in (None, "", "None")]
if not rows:
return False
col_w = (_MR - _ML) / 2
i = 0
while i < len(rows):
c.ensure(16)
for col in range(2):
if i + col < len(rows):
k, v = rows[i + col]
x = _ML + 6 + col * col_w
c.text(x, c.y - 11, f"{k}:", 9, GRAY, bold=True)
c.text(x + _text_w(f"{k}: ", 9, True), c.y - 11, _fit(str(v), 9, col_w - 90), 9, INK)
c.y -= 16
i += 2
c.y -= 4
return True
# ---------------- Cliente ----------------
section("Cliente")
if not kv_block([
("Cliente", client.get("name")), ("RFC", client.get("rfc")),
("Correo", client.get("email")), ("Teléfono", client.get("phone")),
]):
c.text(_ML + 6, c.y - 11, "", 9, GRAY)
c.y -= 16
# ---------------- Carga / Ruta (solo si hay datos) ----------------
if [v for _, v in cargo if v not in (None, "", "None")]:
section("Información de la carga")
kv_block(cargo)
if [v for _, v in route if v not in (None, "", "None")]:
section("Ruta logística")
kv_block(route)
# ---------------- Costos ----------------
section("Costos cotizados")
x_con, x_cant, x_tar, x_imp = _ML, 372, 460, _MR - 6
row_h = 18
# encabezado de tabla
c.ensure(row_h)
c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ACC)
c.text(x_con + 6, c.y - 13, "Concepto", 9, (1, 1, 1), bold=True)
c.text(x_cant, c.y - 13, "Cant.", 9, (1, 1, 1), bold=True, right=True)
c.text(x_tar, c.y - 13, "Tarifa", 9, (1, 1, 1), bold=True, right=True)
c.text(x_imp, c.y - 13, "Importe", 9, (1, 1, 1), bold=True, right=True)
c.y -= row_h
z = False
for it in items:
code = str(it.get("concept") or "")
label = CONCEPT_LABELS.get(code, code)
desc = str(it.get("description") or "")
if desc:
label = f"{label} - {desc}"
qty = Decimal(str(it.get("quantity") or 0))
unit = Decimal(str(it.get("unit_sale") or 0))
amount = (qty * unit).quantize(Decimal("0.01"))
c.ensure(row_h)
if z:
c.rect(_ML, c.y - row_h, _MR - _ML, row_h, ZEBRA)
c.text(x_con + 6, c.y - 13, _fit(label, 9, x_cant - x_con - 40), 9, INK)
c.text(x_cant, c.y - 13, _num(qty), 9, INK, right=True)
c.text(x_tar, c.y - 13, _money(unit), 9, INK, right=True)
c.text(x_imp, c.y - 13, _money(amount), 9, INK, right=True)
c.y -= row_h
z = not z
if not items:
c.text(_ML + 6, c.y - 13, "Sin conceptos.", 9, GRAY)
c.y -= row_h
# borde de la tabla
c.line(_ML, c.y, _MR, c.y, LINE)
c.y -= 12
# ---------------- Totales (caja derecha) ----------------
tb_x, tb_w = 360, _MR - 360
c.ensure(58)
c.rect(tb_x, c.y - 58, tb_w, 58, ZEBRA)
c.line(tb_x, c.y, tb_x, c.y - 58, LINE)
ty = c.y - 16
c.text(tb_x + 10, ty, "Subtotal", 9.5, GRAY, bold=True)
c.text(_MR - 8, ty, f"{currency} {_money(subtotal)}", 9.5, INK, right=True)
ty -= 15
c.text(tb_x + 10, ty, "IVA", 9.5, GRAY, bold=True)
c.text(_MR - 8, ty, "según aplique", 9, GRAY, right=True)
ty -= 6
c.rect(tb_x, ty - 20, tb_w, 20, ACC)
c.text(tb_x + 10, ty - 14, "TOTAL", 10, (1, 1, 1), bold=True)
c.text(_MR - 8, ty - 14, f"{currency} {_money(subtotal)} + IVA", 10, (1, 1, 1), bold=True, right=True)
c.y -= 70
# ---------------- Condiciones ----------------
if terms:
section("Condiciones comerciales")
for para in terms.splitlines():
for ln in (_wrap(para, 108) or [""]):
c.ensure(13)
c.text(_ML + 6, c.y - 10, ln, 8.8, GRAY)
c.y -= 12
c.y -= 4
# pie en todas las páginas
for ops in c.pages:
if footer:
r, g, b = GRAY
ops.append(f"BT /F1 8 Tf {r:.3f} {g:.3f} {b:.3f} rg 1 0 0 1 {_ML} 34 Tm ({_esc(_fit(footer, 8, _MR - _ML))}) Tj ET")
ops.append(f"{ACC[0]:.3f} {ACC[1]:.3f} {ACC[2]:.3f} rg 0 0 {_W} 6 re f")
# ---------------- Ensamblado ----------------
streams = ["\n".join(ops).encode("latin-1", "replace") for ops in c.pages]
objects: list[bytes] = []
def add(obj: bytes):
objects.append(obj)
n_pages = len(c.pages)
has_img = 1 if logo else 0
# numeración: 1 catalog, 2 pages, 3 F1, 4 F2, [5 img], luego páginas y streams
img_num = 5 if has_img else None
base = 6 if has_img else 5
page_nums = list(range(base, base + n_pages))
content_nums = list(range(base + n_pages, base + 2 * n_pages))
kids = " ".join(f"{n} 0 R" for n in page_nums)
add(b"<< /Type /Catalog /Pages 2 0 R >>")
add(f"<< /Type /Pages /Kids [{kids}] /Count {n_pages} >>".encode("latin-1"))
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
add(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
if logo:
jpeg, lw, lh = logo
add(
(
f"<< /Type /XObject /Subtype /Image /Width {lw} /Height {lh} "
f"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {len(jpeg)} >>\n"
).encode("latin-1") + b"stream\n" + jpeg + b"\nendstream"
)
for i in range(n_pages):
res = "/Font << /F1 3 0 R /F2 4 0 R >>"
if has_img and i == 0:
res += f" /XObject << /Im0 {img_num} 0 R >>"
add(
(
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {_W} {_H}] "
f"/Resources << {res} >> /Contents {content_nums[i]} 0 R >>"
).encode("latin-1")
)
for stream in streams:
add(b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream")
out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
offsets = []
for i, obj in enumerate(objects, start=1):
offsets.append(len(out))
out += f"{i} 0 obj\n".encode("latin-1") + obj + b"\nendobj\n"
xref_pos = len(out)
total = len(objects) + 1
out += f"xref\n0 {total}\n".encode("latin-1") + b"0000000000 65535 f \n"
for off in offsets:
out += f"{off:010d} 00000 n \n".encode("latin-1")
out += f"trailer\n<< /Size {total} /Root 1 0 R >>\nstartxref\n{xref_pos}\n%%EOF".encode("latin-1")
return bytes(out)

View File

@@ -0,0 +1,241 @@
"""PDF de cotización, configuración de marca por tenant y envío por correo."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from .models import Quote, QuoteItem, QuoteSettings
from .pdf import build_quote_pdf
from .service import get_quote
logger = logging.getLogger(__name__)
DEFAULT_TERMS = (
"Tarifas sujetas a disponibilidad de espacio.\n"
"Cualquier variación en peso o volumen generará ajuste tarifario.\n"
"No incluye cargos extraordinarios, maniobras especiales o servicios no especificados.\n"
"Tarifas sujetas a revisión por parte de la línea transportista y autoridades correspondientes."
)
# ---------------- Configuración de marca ----------------
def get_settings(db: Session, tenant_id: int, company_id: int) -> QuoteSettings | None:
return (
db.query(QuoteSettings)
.filter(QuoteSettings.tenant_id == tenant_id, QuoteSettings.company_id == company_id,
QuoteSettings.deleted_at.is_(None))
.first()
)
def upsert_settings(db: Session, tenant_id: int, company_id: int, data: dict) -> QuoteSettings:
obj = get_settings(db, tenant_id, company_id)
if obj is None:
obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id)
db.add(obj)
for field, value in data.items():
if value is not None:
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def set_logo_key(db: Session, tenant_id: int, company_id: int, file_key: str) -> QuoteSettings:
obj = get_settings(db, tenant_id, company_id)
if obj is None:
obj = QuoteSettings(tenant_id=tenant_id, company_id=company_id)
db.add(obj)
obj.logo_file_key = file_key
db.commit()
db.refresh(obj)
return obj
def _company_row(db: Session, company_id: int) -> dict:
try:
row = db.execute(
text("SELECT name, rfc, logo FROM a76.company WHERE id = :c"), {"c": company_id}
).first()
if row:
return {"name": row[0], "rfc": row[1], "logo": row[2]}
except Exception:
pass
return {}
# ---------------- Construcción del PDF ----------------
def build_pdf_bytes(db: Session, quote: Quote, tenant_id: int, company_id: int) -> bytes:
items = (
db.query(QuoteItem)
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
.order_by(QuoteItem.id.asc())
.all()
)
account = (
db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None
)
sr = (
db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
if quote.service_request_id else None
)
settings = get_settings(db, tenant_id, company_id)
company = _company_row(db, company_id)
# Emisor: config del tenant con respaldo en a76.company
emitter = {
"name": (settings.emitter_name if settings else None) or company.get("name") or "Emisor",
"rfc": (settings.emitter_rfc if settings else None) or company.get("rfc"),
"address": settings.emitter_address if settings else None,
"phone": settings.emitter_phone if settings else None,
"email": settings.emitter_email if settings else None,
"website": settings.emitter_website if settings else None,
}
accent = (settings.accent_color if settings else None) or "#12294c"
prefix = (settings.quote_prefix if settings else None) or "COT"
terms = quote.terms or (settings.default_terms if settings else None) or DEFAULT_TERMS
footer = settings.footer_note if settings else None
# Logo (MinIO)
logo_bytes = None
logo_key = settings.logo_file_key if settings else None
if logo_key:
try:
from core.storage_s3 import get_object_bytes
logo_bytes = get_object_bytes(logo_key)
except Exception as exc:
logger.warning("No se pudo leer el logo del tarifario: %s", exc)
reference = quote.reference or f"{prefix}-{datetime.now().strftime('%Y%m%d')}-{quote.id:03d}"
head = {
"reference": reference,
"issue_date": quote.issue_date.isoformat() if quote.issue_date else None,
"valid_until": quote.valid_until.isoformat() if quote.valid_until else None,
"owner": quote.owner_user_id or "-",
"status": quote.status,
}
client = {
"name": account.name if account else None,
"rfc": account.rfc if account else None,
"email": account.email if account else None,
"phone": account.phone if account else None,
}
cargo = []
route = []
if sr:
cargo = [
("Tipo de mercancía", sr.cargo_type), ("Descripción", sr.commodity),
("Peso", str(sr.weight) if sr.weight is not None else None),
("Volumen", str(sr.volume) if sr.volume is not None else None),
("Tipo de carga", sr.load_type), ("Equipo", sr.container_equipment),
]
route = [
("Operación", sr.operation_type), ("Modo", sr.transport_mode),
("Servicio", sr.service_type), ("Incoterm", sr.incoterm),
("Origen", sr.origin), ("Destino", sr.destination),
("Fecha requerida", sr.required_date.isoformat() if sr.required_date else None),
]
return build_quote_pdf(
emitter=emitter, head=head, client=client, cargo=cargo, route=route,
items=[{"concept": i.concept, "description": i.description, "quantity": i.quantity, "unit_sale": i.unit_sale} for i in items],
currency=quote.currency, subtotal=quote.total_sale, terms=terms, footer=footer,
logo_bytes=logo_bytes, accent=accent,
)
def _store_pdf(db: Session, quote: Quote, tenant_id: int, company_id: int, pdf_bytes: bytes) -> str:
from core.storage_s3 import put_object_bytes
ref = (quote.reference or f"cot-{quote.id}").replace("/", "-")
key = f"tenants/{tenant_id}/companies/{company_id}/crm-quotes/{quote.id}/cotizacion-{ref}.pdf"
put_object_bytes(key, pdf_bytes, content_type="application/pdf")
quote.pdf_file_key = key
db.commit()
return key
def get_pdf_url(db: Session, quote_id: int, tenant_id: int, company_id: int) -> str:
from core.storage_s3 import presigned_get_url
quote = get_quote(db, quote_id, tenant_id, company_id)
pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id)
key = _store_pdf(db, quote, tenant_id, company_id, pdf_bytes)
return presigned_get_url(key)
# ---------------- Envío por correo ----------------
async def send_quote_email(
db: Session, quote_id: int, tenant_id: int, company_id: int,
to: str | None, subject: str | None, message: str | None,
) -> dict:
import ssl
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
from core.config import settings as cfg
quote = get_quote(db, quote_id, tenant_id, company_id)
account = db.query(Account).filter(Account.id == quote.account_id).first() if quote.account_id else None
recipient = to or (account.email if account else None)
if not recipient:
raise HTTPException(status_code=400, detail="No hay correo destino (captura uno o pon el correo del cliente).")
pdf_bytes = build_pdf_bytes(db, quote, tenant_id, company_id)
_store_pdf(db, quote, tenant_id, company_id, pdf_bytes)
ref = quote.reference or f"COT-{quote.id}"
msg = MIMEMultipart()
msg["From"] = f"{cfg.SMTP_FROM_NAME} <{cfg.SMTP_USER}>"
msg["To"] = recipient
msg["Subject"] = subject or f"Cotización {ref}"
html = (
"<div style='font-family:Arial,sans-serif;color:#333;max-width:600px'>"
f"<p>{(message or 'Adjunto la cotización solicitada. Quedamos atentos.').replace(chr(10), '<br>')}</p>"
f"<p style='color:#6b7280;font-size:12px'>Cotización {ref}</p></div>"
)
msg.attach(MIMEText(html, "html"))
part = MIMEBase("application", "pdf")
part.set_payload(pdf_bytes)
encoders.encode_base64(part)
part.add_header("Content-Disposition", f'attachment; filename="cotizacion-{ref}.pdf"')
msg.attach(part)
if not (cfg.SMTP_USER and cfg.SMTP_PASSWORD):
raise HTTPException(status_code=503, detail="El correo saliente (SMTP) no está configurado en el servidor.")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
# Puerto 465 = SSL implícito; los demás (587/2525/…) = STARTTLS.
await aiosmtplib.send(
msg,
hostname=cfg.SMTP_HOST,
port=cfg.SMTP_PORT,
username=cfg.SMTP_USER,
password=cfg.SMTP_PASSWORD,
use_tls=(cfg.SMTP_PORT == 465),
start_tls=(cfg.SMTP_PORT != 465),
tls_context=ctx,
validate_certs=False,
timeout=30,
)
except Exception as exc:
logger.error("Error enviando cotización %s: %s", quote_id, exc)
raise HTTPException(status_code=502, detail=f"No se pudo enviar el correo: {exc}")
# Marca como enviada
if quote.status == "borrador":
quote.status = "enviada"
quote.sent_at = datetime.now(timezone.utc)
db.commit()
return {"sent_to": recipient, "reference": ref}

View File

@@ -1,22 +1,76 @@
from fastapi import APIRouter, Depends, Query, status
from fastapi import APIRouter, Depends, File, Query, Response, UploadFile, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from . import pdf_service, service
from .dto import (
QuoteCreate,
QuoteItemCreate,
QuoteItemResponse,
QuoteItemUpdate,
QuoteResponse,
QuoteSettingsInput,
QuoteSettingsResponse,
QuoteUpdate,
SendQuoteEmailRequest,
)
router = APIRouter()
# ----- Configuración de marca del formato de cotización -----
@router.get("/quote-settings", response_model=QuoteSettingsResponse)
def get_quote_settings(
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id)
return obj or QuoteSettingsResponse()
@router.put("/quote-settings", response_model=QuoteSettingsResponse)
def save_quote_settings(
payload: QuoteSettingsInput,
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return pdf_service.upsert_settings(db, current_user["tenant_id"], company_id, payload.model_dump(exclude_unset=True))
@router.post("/quote-settings/logo", response_model=QuoteSettingsResponse)
async def upload_quote_logo(
company_id: int = Query(...),
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
from core.storage_s3 import put_object_bytes
tenant_id = current_user["tenant_id"]
content = await file.read()
safe = (file.filename or "logo").replace("/", "-")
key = f"tenants/{tenant_id}/companies/{company_id}/crm-quote-logo/{safe}"
put_object_bytes(key, content, content_type=file.content_type or "image/png")
return pdf_service.set_logo_key(db, tenant_id, company_id, key)
@router.get("/quote-settings/logo-url")
def get_logo_url(
company_id: int = Query(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
from core.storage_s3 import presigned_get_url
obj = pdf_service.get_settings(db, current_user["tenant_id"], company_id)
if not obj or not obj.logo_file_key:
return {"url": None}
return {"url": presigned_get_url(obj.logo_file_key)}
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@@ -98,6 +152,39 @@ def reject_quote(
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.get("/quotes/{quote_id}/pdf")
def quote_pdf(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Devuelve el PDF de la cotización directamente (vía backend, sin exponer MinIO)."""
tenant_id = current_user["tenant_id"]
quote = service.get_quote(db, quote_id, tenant_id, company_id)
pdf_bytes = pdf_service.build_pdf_bytes(db, quote, tenant_id, company_id)
ref = (quote.reference or f"cot-{quote.id}").replace("/", "-")
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="cotizacion-{ref}.pdf"'},
)
@router.post("/quotes/{quote_id}/send-email")
async def quote_send_email(
quote_id: int,
payload: SendQuoteEmailRequest,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Genera el PDF y lo envía por correo (al cliente o al destinatario indicado)."""
return await pdf_service.send_quote_email(
db, quote_id, current_user["tenant_id"], company_id, payload.to, payload.subject, payload.message
)
@router.post("/quotes/{quote_id}/clone", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
def clone_quote(
quote_id: int,

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,8 @@ from .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router
from .pipelines.routes import router as pipelines_router
from .quotes.routes import router as quotes_router
from .rates.routes import cost_router as rates_cost_router
from .rates.routes import router as rates_router
from .service_requests.routes import router as service_requests_router
from .suppliers.routes import router as suppliers_router
from .uploads.routes import router as uploads_router
@@ -44,3 +46,5 @@ router.include_router(activities_router)
router.include_router(metrics_router)
router.include_router(catalogs_router)
router.include_router(uploads_router)
router.include_router(rates_router)
router.include_router(rates_cost_router)

View File

@@ -29,9 +29,8 @@ server {
# ---- HTTPS ----
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name testing.crm.aduanasoft.com;
ssl_certificate /etc/letsencrypt/live/testing.crm.aduanasoft.com/fullchain.pem;
@@ -50,6 +49,13 @@ server {
# Subida de documentos (máx. 25 MB en la app) + margen
client_max_body_size 30m;
# Buffers grandes para headers de respuesta: /auth/sso setea el JWT (fragmentado
# si supera ~4KB) + refresh/id_token/tenant como cookies → el header excede el
# buffer default de nginx (evita "upstream sent too big header" → 502).
proxy_buffer_size 32k;
proxy_buffers 16 32k;
proxy_busy_buffers_size 64k;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;

View File

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

View File

@@ -0,0 +1,64 @@
/**
* Cliente API — Catálogos de referencia del CRM (SAT/ISO + del cliente).
* T2026-07-081/082.
*/
import { api, type ApiResponse } from '$lib/api';
export interface CatalogItem {
id: number;
catalog: string;
code: string;
label: string;
parent_catalog?: string | null;
parent_code?: string | null;
tenant_id: number | null;
sort_order: number;
is_active: boolean;
is_system: boolean;
extra?: Record<string, unknown> | null;
}
export interface CatalogMeta {
catalog: string;
label: string;
scope: 'global' | 'tenant';
is_system: boolean;
count: number;
}
export interface CatalogItemInput {
code: string;
label: string;
parent_catalog?: string | null;
parent_code?: string | null;
sort_order?: number;
is_active?: boolean;
}
function qp(companyId: number, extra?: Record<string, string | number | boolean | 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 referenceCatalogsAPI = {
/** Metadata de todos los catálogos (para la pantalla de administración). */
meta: (companyId: number) => unwrap<CatalogMeta[]>(api.get(`/v1/crm/catalogs?${qp(companyId)}`)),
/** Opciones activas de un catálogo (global + del tenant), con dependiente opcional. */
list: (catalog: string, companyId: number, parentCode?: string) =>
unwrap<CatalogItem[]>(api.get(`/v1/crm/catalogs/${catalog}?${qp(companyId, { parent_code: parentCode })}`)),
/** Todas las opciones incluyendo inactivas (administración). */
listAll: (catalog: string, companyId: number) =>
unwrap<CatalogItem[]>(api.get(`/v1/crm/catalogs/${catalog}?${qp(companyId, { include_inactive: true })}`)),
create: (catalog: string, companyId: number, data: CatalogItemInput, scope: 'tenant' | 'global' = 'tenant') =>
api.post(`/v1/crm/catalogs/${catalog}?${qp(companyId, { scope })}`, data) as Promise<ApiResponse<CatalogItem>>,
update: (catalog: string, id: number, companyId: number, data: Partial<CatalogItemInput>) =>
api.patch(`/v1/crm/catalogs/${catalog}/${id}?${qp(companyId)}`, data) as Promise<ApiResponse<CatalogItem>>,
remove: (catalog: string, id: number, companyId: number) =>
api.delete(`/v1/crm/catalogs/${catalog}/${id}?${qp(companyId)}`) as Promise<ApiResponse<void>>
};

View File

@@ -157,7 +157,32 @@ export const quotesAPI = {
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)}`))
items: (quoteId: number, companyId: number) => unwrap<QuoteItem[]>(api.get(`/v1/crm/quotes/${quoteId}/items?${qp(companyId)}`)),
pdfBlob: (id: number, companyId: number) => (api as any).getBlob(`/v1/crm/quotes/${id}/pdf?${qp(companyId)}`) as Promise<Blob>,
sendEmail: (id: number, companyId: number, body: { to?: string | null; subject?: string | null; message?: string | null }) =>
unwrap<{ sent_to: string; reference: string }>(api.post(`/v1/crm/quotes/${id}/send-email?${qp(companyId)}`, body))
};
// ---------- Configuración de marca del formato de cotización ----------
export interface QuoteSettings {
id?: number | null;
emitter_name?: string | null; emitter_rfc?: string | null; emitter_address?: string | null;
emitter_phone?: string | null; emitter_email?: string | null; emitter_website?: string | null;
logo_file_key?: string | null; accent_color?: string | null; quote_prefix?: string | null;
default_terms?: string | null; footer_note?: string | null;
}
export const quoteSettingsAPI = {
get: (companyId: number) => unwrap<QuoteSettings>(api.get(`/v1/crm/quote-settings?${qp(companyId)}`)),
save: (companyId: number, data: QuoteSettings) => unwrap<QuoteSettings>(api.put(`/v1/crm/quote-settings?${qp(companyId)}`, data)),
logoUrl: (companyId: number) => unwrap<{ url: string | null }>(api.get(`/v1/crm/quote-settings/logo-url?${qp(companyId)}`)),
async uploadLogo(companyId: number, file: File): Promise<QuoteSettings> {
const fd = new FormData();
fd.append('file', file);
const res = await (api as any).request(`/v1/crm/quote-settings/logo?${qp(companyId)}`, { method: 'POST', body: fd });
if (res.error) throw new Error(res.error);
return res.data as QuoteSettings;
}
};
// ---------- Catálogos de referencia (Incoterms, participantes) ----------

View File

@@ -0,0 +1,107 @@
/**
* Cliente API — Módulo Tarifario (tarifarios, rutas, import Excel, costeo).
*/
import { api } from '$lib/api';
export type RateMode = 'aereo' | 'maritimo_fcl' | 'maritimo_lcl' | 'terrestre';
export interface RateBreak { from_qty: number; rate: number; }
export interface RateLane {
id: number; rate_sheet_id: number;
origin: string | null; destination: string | null; region: string | null;
equipment_type: string | null; rate_unit: string | null;
min_charge: number | null; flat_rate: number | null; transit_days: number | null; notes: string | null;
breaks: RateBreak[];
}
export interface RateSheet {
id: number; supplier_id: number | null; mode: RateMode; name: string;
currency: string | null; valid_from: string | null; valid_to: string | null;
default_origin: string | null; status: string; notes: string | null;
source_file: string | null; created_by: string | null; updated_by: string | null;
created_at: string; updated_at: string; lane_count: number | null;
}
export type RateSheetInput = Partial<Omit<RateSheet, 'id' | 'created_at' | 'updated_at' | 'lane_count' | 'source_file' | 'created_by' | 'updated_by'>> & {
mode: RateMode; name: string;
};
export interface RateCharge {
id: number; rate_sheet_id: number | null; rate_lane_id: number | null;
concept: string; charge_type: string; value: number | null; condition: string | null;
}
export interface RateChargeInput { concept: string; charge_type: string; value?: number | null; condition?: string | null; rate_lane_id?: number | null; }
export interface ImportPreviewRow { row: number; data: Record<string, unknown>; ok: boolean; warnings: string[]; errors: string[]; }
export interface ImportPreview { mode: RateMode; total: number; valid: number; rows: ImportPreviewRow[]; columns: string[]; }
export interface CostRequest {
mode: RateMode; origin?: string | null; destination?: string | null; on_date?: string | null;
gross_weight_kg?: number | null; volume_m3?: number | null; equipment_type?: string | null;
quantity?: number; dangerous?: boolean;
}
export interface CostChargeLine { concept: string; amount: number; }
export interface CostOption {
rate_sheet_id: number; rate_sheet_name: string; supplier_id: number | null; currency: string | null;
chargeable: number | null; base_cost: number; charges: CostChargeLine[]; total_cost: number;
transit_days: number | null; detail: string | null;
}
export interface CostResult { request: CostRequest; options: CostOption[]; }
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 rateSheetsAPI = {
list: (companyId: number, params?: { mode?: string; supplier_id?: number }) =>
unwrap<RateSheet[]>(api.get(`/v1/crm/rate-sheets?${qp(companyId, params)}`)),
get: (id: number, companyId: number) => unwrap<RateSheet>(api.get(`/v1/crm/rate-sheets/${id}?${qp(companyId)}`)),
create: (data: RateSheetInput, companyId: number) => unwrap<RateSheet>(api.post(`/v1/crm/rate-sheets?${qp(companyId)}`, data)),
update: (id: number, data: Partial<RateSheetInput>, companyId: number) => unwrap<RateSheet>(api.patch(`/v1/crm/rate-sheets/${id}?${qp(companyId)}`, data)),
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}?${qp(companyId)}`)),
lanes: (id: number, companyId: number) => unwrap<RateLane[]>(api.get(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`)),
addLane: (id: number, data: Partial<RateLane>, companyId: number) => unwrap<RateLane>(api.post(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`, data)),
removeLane: (id: number, laneId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/lanes/${laneId}?${qp(companyId)}`)),
charges: (id: number, companyId: number) => unwrap<RateCharge[]>(api.get(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`)),
addCharge: (id: number, data: RateChargeInput, companyId: number) => unwrap<RateCharge>(api.post(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`, data)),
removeCharge: (id: number, chargeId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/charges/${chargeId}?${qp(companyId)}`)),
/** Descarga la plantilla Excel del modo. */
async downloadTemplate(mode: RateMode, companyId: number): Promise<void> {
const blob = await api.getBlob(`/v1/crm/rate-sheets/template?${qp(companyId, { mode })}`);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `plantilla_tarifario_${mode}.xlsx`;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
},
async importPreview(mode: RateMode, file: File, companyId: number): Promise<ImportPreview> {
const fd = new FormData();
fd.append('mode', mode); fd.append('file', file);
const res = await (api as any).request(`/v1/crm/rate-sheets/import/preview?${qp(companyId)}`, { method: 'POST', body: fd });
if (res.error) throw new Error(res.error);
return res.data as ImportPreview;
},
async importSheet(companyId: number, header: { mode: RateMode; name: string; supplier_id?: number | null; currency?: string; valid_from?: string | null; valid_to?: string | null; default_origin?: string | null }, file: File): Promise<RateSheet> {
const fd = new FormData();
fd.append('mode', header.mode); fd.append('name', header.name);
if (header.supplier_id != null) fd.append('supplier_id', String(header.supplier_id));
if (header.currency) fd.append('currency', header.currency);
if (header.valid_from) fd.append('valid_from', header.valid_from);
if (header.valid_to) fd.append('valid_to', header.valid_to);
if (header.default_origin) fd.append('default_origin', header.default_origin);
fd.append('file', file);
const res = await (api as any).request(`/v1/crm/rate-sheets/import?${qp(companyId)}`, { method: 'POST', body: fd });
if (res.error) throw new Error(res.error);
return res.data as RateSheet;
},
quote: (req: CostRequest, companyId: number) => unwrap<CostResult>(api.post(`/v1/crm/rate-quote?${qp(companyId)}`, req))
};

View File

@@ -24,10 +24,12 @@ export interface Account {
status: AccountStatus;
commercial_classification: string | null;
preferred_contact_method: string | null;
preferred_contact_other: string | null;
language: string | null;
email: string | null;
phone: string | null;
website: string | null;
commercial_observations: string | null;
tax_regime: string | null;
cfdi_use: string | null;
payment_method: string | null;
@@ -66,6 +68,7 @@ export interface Supplier {
person_type: string | null;
status: AccountStatus;
classifications: string[];
classification_other: string | null;
services_offered: string | null;
coverage: string | null;
countries: string[];

View File

@@ -1,15 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
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';
import { ACCOUNT_TYPES } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
// `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';
// Catálogos que usa este formulario (se precargan; los selects se llenan solos).
onMount(() => {
void crmCatalogs.preload([
'tipo_registro', 'tipo_persona', 'estatus', 'giro', 'clasificacion_cliente',
'medio_contacto', 'idioma', 'regimen_fiscal', 'uso_cfdi', 'metodo_pago',
'forma_pago', 'moneda'
]);
});
</script>
{#if tab === 'generales'}
@@ -18,28 +26,32 @@
<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 de registro</span><select class={inputCls} bind:value={form.record_type}>{#each crmCatalogs.options('tipo_registro') 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 crmCatalogs.options('tipo_persona') 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><select class={inputCls} bind:value={form.industry}><option value={undefined}>—</option>{#each crmCatalogs.options('giro') as g (g.value)}<option value={g.value}>{g.label}</option>{/each}</select></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>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estatus</span><select class={inputCls} bind:value={form.status}>{#each crmCatalogs.options('estatus') 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">Clasificación del cliente</span><select class={inputCls} bind:value={form.commercial_classification}><option value={undefined}>—</option>{#each crmCatalogs.options('clasificacion_cliente') 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 crmCatalogs.options('medio_contacto') as m (m.value)}<option value={m.value}>{m.label}</option>{/each}</select></label>
{#if form.preferred_contact_method === 'otro'}
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Especifica el medio de contacto</span><input class={inputCls} bind:value={form.preferred_contact_other} placeholder="Indica cuál" /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Idioma</span><select class={inputCls} bind:value={form.language}><option value={undefined}>—</option>{#each crmCatalogs.options('idioma') as i (i.value)}<option value={i.value}>{i.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={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>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Observaciones generales</span><textarea rows="3" class={inputCls} bind:value={form.commercial_observations}></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">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">Régimen fiscal</span><select class={inputCls} bind:value={form.tax_regime}><option value={undefined}>—</option>{#each crmCatalogs.options('regimen_fiscal') 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">Uso de CFDI</span><select class={inputCls} bind:value={form.cfdi_use}><option value={undefined}>—</option>{#each crmCatalogs.options('uso_cfdi') as u (u.value)}<option value={u.value}>{u.value} {u.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><select class={inputCls} bind:value={form.payment_method}><option value={undefined}>—</option>{#each crmCatalogs.options('metodo_pago') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><select class={inputCls} bind:value={form.payment_form}><option value={undefined}>—</option>{#each crmCatalogs.options('forma_pago') as f (f.value)}<option value={f.value}>{f.value} {f.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}><option value={undefined}>—</option>{#each crmCatalogs.options('moneda') as c (c.value)}<option value={c.value}>{c.value} {c.label}</option>{/each}</select></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>
@@ -47,7 +59,7 @@
</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">Comentarios generales</span><textarea rows="4" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</div>
{/if}

View File

@@ -8,10 +8,16 @@
addressesAPI, contactsAPI, documentsAPI,
type Address, type Contact, type Document, type AddressInput, type ContactInput, type DocumentInput
} from '$lib/api/crm';
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
import { DOC_TYPES, labelOf } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { onMount } from 'svelte';
import { uploadFile, uploadUrl } from '$lib/api/uploads';
import { toast } from 'svelte-sonner';
onMount(() => {
void crmCatalogs.preload(['tipo_domicilio', 'area', 'pais']);
});
// Dueño de los registros relacionados y qué sección mostrar
let {
ownerType,
@@ -31,7 +37,7 @@
let activeModal = $state<'address' | 'contact' | 'document' | null>(null);
let saving = $state(false);
let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MX', is_primary: false });
let addressForm = $state<AddressInput>({ address_type: 'fiscal', country: 'MEX', is_primary: false });
let contactForm = $state<ContactInput>({ first_name: '' });
let documentForm = $state<DocumentInput>({ doc_type: 'constancia_fiscal', name: '' });
let uploading = $state(false);
@@ -70,6 +76,11 @@
if (companyId && ownerId) void load(companyId);
});
// Estado depende del país seleccionado (catálogo dependiente)
$effect(() => {
if (addressForm.country) void crmCatalogs.ensure('estado', addressForm.country);
});
async function load(cid: number) {
try {
[addresses, contacts, documents] = await Promise.all([
@@ -83,7 +94,7 @@
}
function openModal(kind: 'address' | 'contact' | 'document') {
if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MX', is_primary: false };
if (kind === 'address') addressForm = { address_type: 'fiscal', country: 'MEX', is_primary: false };
if (kind === 'contact') contactForm = { first_name: '' };
if (kind === 'document') documentForm = { doc_type: 'constancia_fiscal', name: '' };
activeModal = kind;
@@ -174,7 +185,7 @@
<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>{crmCatalogs.label('tipo_domicilio', 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>
@@ -204,7 +215,7 @@
{#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 class="text-sm">{[c.job_title, c.area ? crmCatalogs.label('area', 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>
@@ -252,15 +263,15 @@
{#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">Tipo de domicilio</span><select class={inputCls} bind:value={addressForm.address_type}>{#each crmCatalogs.options('tipo_domicilio') 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"><span class="font-medium">Municipio / Ciudad</span><input class={inputCls} bind:value={addressForm.city} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Estado</span>{#if crmCatalogs.options('estado', addressForm.country).length > 0}<select class={inputCls} bind:value={addressForm.state}><option value={undefined}>—</option>{#each crmCatalogs.options('estado', addressForm.country) as s (s.value)}<option value={s.value}>{s.label}</option>{/each}</select>{:else}<input class={inputCls} bind:value={addressForm.state} placeholder="Estado / provincia" />{/if}</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">País</span><select class={inputCls} bind:value={addressForm.country}><option value={undefined}>—</option>{#each crmCatalogs.options('pais') as p (p.value)}<option value={p.value}>{p.label}</option>{/each}</select></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>
@@ -271,7 +282,7 @@
<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">Área / Departamento</span><select class={inputCls} bind:value={contactForm.area}><option value={undefined}>—</option>{#each crmCatalogs.options('area') 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>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { SupplierInput } from '$lib/api/crm';
import { SUPPLIER_CLASSIFICATIONS, COVERAGE, ACCOUNT_STATUS, PERSON_TYPES } from '$lib/components/crm/format';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
// listas separadas por coma también son bindables (el padre las convierte a arreglo)
let {
@@ -21,6 +22,15 @@
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
const hasOtro = $derived((form.classifications ?? []).includes('otro'));
onMount(() => {
void crmCatalogs.preload([
'tipo_persona', 'estatus', 'clasificacion_proveedor', 'cobertura', 'moneda',
'regimen_fiscal', 'metodo_pago', 'forma_pago'
]);
});
</script>
{#if tab === 'generales'}
@@ -28,25 +38,28 @@
<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>
<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 crmCatalogs.options('tipo_persona') 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 crmCatalogs.options('estatus') 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>
<p class="mb-2 text-sm font-medium">Clasificación del proveedor (una o varias)</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each SUPPLIER_CLASSIFICATIONS as c (c.value)}
{#each crmCatalogs.options('clasificacion_proveedor') 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>
{#if hasOtro}
<label class="mt-3 flex max-w-md flex-col gap-1 text-sm"><span class="font-medium">Especifica la clasificación "Otro"</span><input class={inputCls} bind:value={form.classification_other} placeholder="Indica cuál" /></label>
{/if}
</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">Cobertura</span><select class={inputCls} bind:value={form.coverage}><option value={undefined}>—</option>{#each crmCatalogs.options('cobertura') 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><select class={inputCls} bind:value={form.quote_currency}><option value={undefined}>—</option>{#each crmCatalogs.options('moneda') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Países donde opera (separados por coma)</span><input class={inputCls} bind:value={countriesStr} placeholder="México, Estados Unidos" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Puertos donde opera</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 donde opera</span><input class={inputCls} bind:value={airportsStr} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Aduanas donde opera</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>
@@ -56,16 +69,16 @@
</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">Régimen fiscal</span><select class={inputCls} bind:value={form.tax_regime}><option value={undefined}>—</option>{#each crmCatalogs.options('regimen_fiscal') 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">Método de pago</span><select class={inputCls} bind:value={form.payment_method}><option value={undefined}>—</option>{#each crmCatalogs.options('metodo_pago') as m (m.value)}<option value={m.value}>{m.value} {m.label}</option>{/each}</select></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><select class={inputCls} bind:value={form.payment_form}><option value={undefined}>—</option>{#each crmCatalogs.options('forma_pago') as f (f.value)}<option value={f.value}>{f.value} {f.label}</option>{/each}</select></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">Comentarios generales</span><textarea rows="4" class={inputCls} bind:value={form.notes}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Notas internas</span><textarea rows="4" class={inputCls} bind:value={form.internal_notes}></textarea></label>
</div>
{/if}

View File

@@ -43,7 +43,7 @@ export const ACCOUNT_STATUS: Option[] = [
export const COMMERCIAL_CLASSIFICATION: Option[] = [
{ value: 'importador', label: 'Importador' },
{ value: 'exportador', label: 'Exportador' },
{ value: 'ambos', label: 'Importador / Exportador' }
{ value: 'importador_exportador', label: 'Importador / Exportador' }
];
export const ACCOUNT_TYPES: Option[] = [
@@ -58,7 +58,7 @@ export const ACCOUNT_TYPES: Option[] = [
export const CONTACT_METHODS: Option[] = [
{ value: 'llamada', label: 'Llamada telefónica' },
{ value: 'correo', label: 'Correo electrónico' },
{ value: 'videollamada', label: 'Videoconferencia' },
{ value: 'videoconferencia', label: 'Videoconferencia' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'otro', label: 'Otro' }
];

View File

@@ -49,9 +49,12 @@ export function getNavMain(): NavMainItem[] {
{ title: 'Contactos', url: '/dashboard/crm/contactos' },
{ title: 'Solicitudes', url: '/dashboard/crm/solicitudes' },
{ title: 'Cotizaciones', url: '/dashboard/crm/cotizaciones' },
{ title: 'Tarifarios', url: '/dashboard/crm/tarifarios' },
{ title: 'Cotizador', url: '/dashboard/crm/cotizador' },
{ title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' },
{ title: 'Oportunidades', url: '/dashboard/crm/oportunidades' },
{ title: 'Actividades', url: '/dashboard/crm/actividades' },
{ title: 'Catálogos', url: '/dashboard/crm/catalogos' },
],
},
{
@@ -89,6 +92,10 @@ export function getNavMain(): NavMainItem[] {
title: 'Configuración',
url: '/dashboard/settings/general',
icon: Settings2,
items: [
{ title: 'General', url: '/dashboard/settings/general' },
{ title: 'Formato de cotización', url: '/dashboard/settings/cotizacion' },
],
},
];
}

View File

@@ -0,0 +1,77 @@
/**
* Store reactivo de catálogos de referencia del CRM.
*
* Carga desde el backend (/v1/crm/catalogs) y cachea por catálogo. Los
* formularios leen `options(catalog)` (reactivo) sin refetch. Los catálogos
* dependientes (Estado por País) se piden con `ensure(catalog, parentCode)`.
*/
import { referenceCatalogsAPI } from '$lib/api/crm/catalogs';
import { companyStore } from '$lib/stores/company.svelte';
import type { Option } from '$lib/components/crm/format';
class CrmCatalogStore {
private _cache = $state<Record<string, Option[]>>({});
private _pending = new Set<string>();
private _companyId: number | null = null;
/** Toma la compañía activa; si cambió, limpia el caché. Devuelve su id (o null). */
private syncCompany(): number | null {
const cid = companyStore.activeCompany?.id ?? null;
if (cid !== this._companyId) {
this._companyId = cid;
this._cache = {};
this._pending.clear();
}
return cid;
}
private keyOf(catalog: string, parentCode?: string): string {
return parentCode ? `${catalog}:${parentCode}` : catalog;
}
/** Opciones de un catálogo ya cargado (vacío si aún no se ha cargado). */
options(catalog: string, parentCode?: string): Option[] {
return this._cache[this.keyOf(catalog, parentCode)] ?? [];
}
/** Descripción de una clave (para listados/detalle). */
label(catalog: string, code: string | null | undefined): string {
if (!code) return '—';
return this.options(catalog).find((o) => o.value === code)?.label ?? code;
}
/** Carga un catálogo (con dependiente opcional) si aún no está en caché. */
async ensure(catalog: string, parentCode?: string): Promise<void> {
const cid = this.syncCompany();
const key = this.keyOf(catalog, parentCode);
if (cid == null || key in this._cache || this._pending.has(key)) return;
this._pending.add(key);
try {
const items = await referenceCatalogsAPI.list(catalog, cid, parentCode);
this._cache[key] = items.map((i) => ({ value: i.code, label: i.label }));
} catch {
this._cache[key] = [];
} finally {
this._pending.delete(key);
}
}
/** Precarga varios catálogos globales en paralelo. */
async preload(catalogs: string[]): Promise<void> {
await Promise.all(catalogs.map((c) => this.ensure(c)));
}
/** Invalida el caché de un catálogo (tras editar en administración). */
invalidate(catalog?: string): void {
if (!catalog) {
this._cache = {};
return;
}
for (const k of Object.keys(this._cache)) {
if (k === catalog || k.startsWith(`${catalog}:`)) delete this._cache[k];
}
this._cache = { ...this._cache };
}
}
export const crmCatalogs = new CrmCatalogStore();

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import { Database, Plus, Trash2, Pencil, Check, X, Lock } 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 { referenceCatalogsAPI, type CatalogItem, type CatalogMeta } from '$lib/api/crm/catalogs';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let metas = $state<CatalogMeta[]>([]);
let selected = $state<CatalogMeta | null>(null);
let items = $state<CatalogItem[]>([]);
let loading = $state(false);
let newCode = $state('');
let newLabel = $state('');
let adding = $state(false);
let editId = $state<number | null>(null);
let editLabel = $state('');
let editActive = $state(true);
const inputCls =
'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
$effect(() => {
const cid = companyId;
if (cid) void loadMetas(cid);
});
async function loadMetas(cid: number) {
try {
metas = await referenceCatalogsAPI.meta(cid);
if (!selected && metas.length) void selectCatalog(metas[0]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos');
}
}
async function selectCatalog(m: CatalogMeta) {
selected = m;
editId = null;
newCode = '';
newLabel = '';
if (!companyId) return;
loading = true;
try {
items = await referenceCatalogsAPI.listAll(m.catalog, companyId);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las opciones');
} finally {
loading = false;
}
}
async function addItem() {
if (!companyId || !selected) return;
const code = newCode.trim();
const label = newLabel.trim();
if (!code || !label) {
toast.error('Clave y descripción son obligatorias');
return;
}
adding = true;
try {
const res = await referenceCatalogsAPI.create(
selected.catalog,
companyId,
{ code, label },
selected.scope
);
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Opción agregada');
newCode = '';
newLabel = '';
crmCatalogs.invalidate(selected.catalog);
await selectCatalog(selected);
} finally {
adding = false;
}
}
function startEdit(it: CatalogItem) {
editId = it.id;
editLabel = it.label;
editActive = it.is_active;
}
async function saveEdit(it: CatalogItem) {
if (!companyId || !selected) return;
const res = await referenceCatalogsAPI.update(selected.catalog, it.id, companyId, {
label: editLabel.trim() || it.label,
is_active: editActive
});
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Opción actualizada');
editId = null;
crmCatalogs.invalidate(selected.catalog);
await selectCatalog(selected);
}
async function del(it: CatalogItem) {
if (!companyId || !selected) return;
if (!confirm(`¿Eliminar la opción "${it.label}"?`)) return;
const res = await referenceCatalogsAPI.remove(selected.catalog, it.id, companyId);
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Opción eliminada');
crmCatalogs.invalidate(selected.catalog);
await selectCatalog(selected);
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Database class="h-6 w-6" /> Catálogos</h1>
<p class="mt-1 text-sm text-muted-foreground">
Administra las opciones de los catálogos del CRM. Los catálogos base del sistema (SAT/ISO) solo se pueden activar/desactivar; los de tu empresa se pueden insertar, editar y borrar.
</p>
</div>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else}
<div class="grid gap-6 lg:grid-cols-[260px_1fr]">
<Card.Root>
<Card.Header><Card.Title class="text-base">Catálogos</Card.Title></Card.Header>
<Card.Content class="space-y-1">
{#each metas as m (m.catalog)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm {selected?.catalog === m.catalog ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}"
onclick={() => selectCatalog(m)}
>
<span class="flex items-center gap-1">{#if m.is_system}<Lock class="h-3 w-3 opacity-60" />{/if}{m.label}</span>
<span class="text-xs text-muted-foreground">{m.count}</span>
</button>
{/each}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-base">{selected?.label ?? 'Opciones'}</Card.Title>
<Card.Description>
{#if selected}
{selected.scope === 'global' ? 'Catálogo global (Aduanasoft)' : 'Catálogo de tu empresa'} · {selected.count} opciones
{#if selected.is_system} · base del sistema (no se borra){/if}
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Clave</span><input class="font-mono {inputCls}" bind:value={newCode} placeholder="clave" /></label>
<label class="flex flex-1 flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newLabel} placeholder="Descripción visible" /></label>
<Button size="sm" onclick={addItem} disabled={adding}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</div>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if items.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin opciones. Agrega la primera arriba.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Clave</Table.Head><Table.Head>Descripción</Table.Head><Table.Head>Origen</Table.Head><Table.Head>Activo</Table.Head><Table.Head class="text-right">Acciones</Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each items as it (it.id)}
<Table.Row>
<Table.Cell class="font-mono text-xs">{it.code}</Table.Cell>
<Table.Cell>
{#if editId === it.id}
<input class="{inputCls} w-full" bind:value={editLabel} />
{:else}
{it.label}
{/if}
</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{it.tenant_id === null ? 'Global' : 'Empresa'}{#if it.is_system} · base{/if}</Table.Cell>
<Table.Cell>
{#if editId === it.id}
<input type="checkbox" class="h-4 w-4 rounded border" bind:checked={editActive} />
{:else}
<span class="inline-flex rounded-full px-2 py-0.5 text-xs {it.is_active ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400' : 'bg-muted text-muted-foreground'}">{it.is_active ? 'Sí' : 'No'}</span>
{/if}
</Table.Cell>
<Table.Cell class="text-right">
{#if editId === it.id}
<Button variant="ghost" size="sm" onclick={() => saveEdit(it)} aria-label="Guardar"><Check class="h-4 w-4 text-emerald-600" /></Button>
<Button variant="ghost" size="sm" onclick={() => (editId = null)} aria-label="Cancelar"><X class="h-4 w-4" /></Button>
{:else}
<Button variant="ghost" size="sm" onclick={() => startEdit(it)} aria-label="Editar"><Pencil class="h-4 w-4" /></Button>
{#if !it.is_system}
<Button variant="ghost" size="sm" onclick={() => del(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
{/if}
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship } from '@lucide/svelte';
import { ArrowLeft, Receipt, Plus, Trash2, Send, Check, X, Ship, FileText, Mail } from '@lucide/svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
@@ -142,6 +142,49 @@
}
}
async function openPdf() {
if (!companyId || !quote) return;
busy = true;
try {
const blob = await quotesAPI.pdfBlob(quote.id, companyId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo generar el PDF');
} finally {
busy = false;
}
}
let showEmail = $state(false);
let emailForm = $state({ to: '', subject: '', message: '' });
function openEmail() {
emailForm = {
to: accounts.find((a) => a.id === quote?.account_id)?.email ?? '',
subject: `Cotización ${quote?.reference ?? ''}`.trim(),
message: 'Adjunto la cotización solicitada. Quedamos atentos a sus comentarios.'
};
showEmail = true;
}
async function sendEmail() {
if (!companyId || !quote) return;
if (!emailForm.to.trim()) { toast.error('Indica el correo destino'); return; }
busy = true;
try {
const res = await quotesAPI.sendEmail(quote.id, companyId, {
to: emailForm.to.trim(), subject: emailForm.subject || null, message: emailForm.message || null
});
toast.success(`Cotización enviada a ${res.sent_to}`);
showEmail = false;
await reload();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo enviar el correo');
} finally {
busy = false;
}
}
function supplierName(id: number | null | undefined): string {
return suppliers.find((s) => s.id === id)?.name ?? '—';
}
@@ -169,8 +212,10 @@
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onclick={openPdf} disabled={busy}><FileText class="mr-1 h-4 w-4" /> Ver PDF</Button>
<Button size="sm" variant="outline" onclick={openEmail} disabled={busy}><Mail class="mr-1 h-4 w-4" /> Enviar por correo</Button>
{#if quote.status === 'borrador'}
<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Enviar</Button>
<Button size="sm" variant="outline" onclick={() => doAction('send')} disabled={busy}><Send class="mr-1 h-4 w-4" /> Marcar enviada</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>
@@ -249,3 +294,21 @@
</Card.Root>
{/if}
</div>
{#if showEmail && quote}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showEmail = false)}>
<div class="w-full max-w-lg rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold"><Mail class="h-4 w-4" /> Enviar cotización por correo</h3>
<div class="grid gap-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Para *</span><input type="email" class={inputCls} bind:value={emailForm.to} placeholder="cliente@empresa.com" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Asunto</span><input class={inputCls} bind:value={emailForm.subject} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Mensaje</span><textarea rows="4" class={inputCls} bind:value={emailForm.message}></textarea></label>
<p class="text-xs text-muted-foreground">Se adjunta el PDF de la cotización con el formato y la marca configurados.</p>
</div>
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (showEmail = false)}>Cancelar</Button>
<Button onclick={sendEmail} disabled={busy}>{busy ? 'Enviando…' : 'Enviar'}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Calculator } 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 { rateSheetsAPI, type CostOption, type RateMode } from '$lib/api/crm/rates';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatMoney } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let f = $state({
mode: 'aereo' as RateMode, origin: '', destination: '', on_date: '',
gross_weight_kg: null as number | null, volume_m3: null as number | null,
equipment_type: '', quantity: 1, dangerous: false
});
let options = $state<CostOption[]>([]);
let calculated = $state(false);
let working = $state(false);
const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre');
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
async function calc() {
if (!companyId) return;
if (!f.destination.trim()) { toast.error('Indica el destino'); return; }
working = true; calculated = false;
try {
const res = await rateSheetsAPI.quote({
mode: f.mode, origin: f.origin || null, destination: f.destination || null,
on_date: f.on_date || null, gross_weight_kg: f.gross_weight_kg, volume_m3: f.volume_m3,
equipment_type: f.equipment_type || null, quantity: f.quantity || 1, dangerous: f.dangerous
}, companyId);
options = res.options; calculated = true;
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo calcular'); }
finally { working = false; }
}
const money = (v: number, c: string | null) => formatMoney(v, c ?? 'USD');
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Calculator class="h-6 w-6" /> Cotizador</h1>
<p class="mt-1 text-sm text-muted-foreground">Calcula el costo por proveedor a partir de los tarifarios vigentes.</p>
</div>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else}
<Card.Root>
<Card.Header><Card.Title class="text-base">Datos de la carga</Card.Title></Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modo *</span>
<select class={inputCls} bind:value={f.mode}>{#each crmCatalogs.options('modo_tarifario') 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">Origen</span><input class={inputCls} bind:value={f.origin} placeholder="NLU / MXZLO" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span><input class={inputCls} bind:value={f.destination} placeholder="FRA / CNSHA" /></label>
{#if isFcl}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de equipo</span>
<select class={inputCls} bind:value={f.equipment_type}><option value=""></option>{#each crmCatalogs.options('tipo_equipo') 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">Cantidad</span><input type="number" min="1" class={inputCls} bind:value={f.quantity} /></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso bruto (kg)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.gross_weight_kg} /></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={f.volume_m3} /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha embarque</span><input type="date" class={inputCls} bind:value={f.on_date} /></label>
<label class="flex items-center gap-2 pt-6 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={f.dangerous} /><span>Mercancía peligrosa (DGR)</span></label>
</div>
<div class="mt-4 flex justify-end"><Button onclick={calc} disabled={working}>{working ? 'Calculando…' : 'Calcular costo'}</Button></div>
</Card.Content>
</Card.Root>
{#if calculated}
<Card.Root>
<Card.Header><Card.Title class="text-base">Opciones ({options.length})</Card.Title>
<Card.Description>Ordenadas por costo total. El precio de venta se define en la cotización (costo + margen).</Card.Description>
</Card.Header>
<Card.Content>
{#if options.length === 0}
<p class="text-sm text-muted-foreground">No hay tarifas vigentes para esa ruta/modo. Verifica que exista un tarifario <b>activo</b> con esa ruta.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Tarifario</Table.Head><Table.Head>Base</Table.Head><Table.Head>Cargos</Table.Head><Table.Head>Total</Table.Head><Table.Head>Detalle</Table.Head><Table.Head>Tránsito</Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each options as o, i (o.rate_sheet_id + '-' + i)}
<Table.Row class={i === 0 ? 'bg-emerald-50/60 dark:bg-emerald-950/20' : ''}>
<Table.Cell class="font-medium">{o.rate_sheet_name}</Table.Cell>
<Table.Cell>{money(o.base_cost, o.currency)}</Table.Cell>
<Table.Cell class="text-xs">{o.charges.length ? o.charges.map((c) => `${c.concept}: ${money(c.amount, o.currency)}`).join(', ') : '—'}</Table.Cell>
<Table.Cell class="font-semibold">{money(o.total_cost, o.currency)}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{o.detail ?? '—'}</Table.Cell>
<Table.Cell>{o.transit_days ?? '—'}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
{/if}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Building2, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import { Building2, Plus, Trash2, Search, Pencil } 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';
@@ -120,8 +120,8 @@
<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" href={`/dashboard/crm/cuentas/${a.id}`} aria-label="Abrir">
<ChevronRight class="h-4 w-4" />
<Button variant="ghost" size="sm" href={`/dashboard/crm/cuentas/${a.id}`}>
<Pencil class="mr-1 h-4 w-4" /> Editar
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(a)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" />

View File

@@ -7,7 +7,7 @@
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 { RECORD_TYPES, ACCOUNT_STATUS, labelOf, formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
@@ -86,6 +86,10 @@
{labelOf(RECORD_TYPES, account.record_type)} · {labelOf(ACCOUNT_STATUS, account.status)}
{#if account.rfc}· <span class="font-mono">{account.rfc}</span>{/if}
</p>
<p class="mt-1 text-xs text-muted-foreground">
ID #{account.id} · Creado {formatDate(account.created_at)}{#if account.created_by} por <span class="font-mono">{account.created_by}</span>{/if}
· Últ. actualización {formatDate(account.updated_at)}{#if account.updated_by} por <span class="font-mono">{account.updated_by}</span>{/if}
</p>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Truck, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
import { Truck, Plus, Trash2, Search, Pencil } 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';
@@ -106,8 +106,8 @@
<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 variant="ghost" size="sm" href={`/dashboard/crm/proveedores/${s.id}`}>
<Pencil class="mr-1 h-4 w-4" /> Editar
</Button>
<Button variant="ghost" size="sm" onclick={() => remove(s)} aria-label="Eliminar">
<Trash2 class="h-4 w-4 text-destructive" />

View File

@@ -7,7 +7,7 @@
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 { COVERAGE, ACCOUNT_STATUS, labelOf, formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
type TabDef = { id: string; label: string; kind: 'info' | 'related'; section?: 'addresses' | 'contacts' | 'documents' };
@@ -104,7 +104,7 @@
</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}
{#if supplier.rfc}· <span class="font-mono">{supplier.rfc}</span>{/if}<br /><span class="text-xs">ID #{supplier.id} · Alta {formatDate(supplier.created_at)}{#if supplier.created_by} por <span class="font-mono">{supplier.created_by}</span>{/if} · Últ. modificación {formatDate(supplier.updated_at)}{#if supplier.updated_by} por <span class="font-mono">{supplier.updated_by}</span>{/if}</span>
</p>
</div>

View File

@@ -0,0 +1,179 @@
<script lang="ts">
import { onMount } from 'svelte';
import { FileSpreadsheet, Plus, Trash2, Search, Pencil, Upload, Download } 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 { rateSheetsAPI, type RateSheet, type RateMode, type ImportPreview } from '$lib/api/crm/rates';
import { suppliersAPI, type Supplier } from '$lib/api/crm';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let sheets = $state<RateSheet[]>([]);
let suppliers = $state<Supplier[]>([]);
let loading = $state(false);
let modeFilter = $state('');
// modal import
let showImport = $state(false);
let imp = $state({ mode: 'aereo' as RateMode, name: '', supplier_id: null as number | null, currency: 'USD', valid_from: '', valid_to: '', default_origin: '' });
let impFile = $state<File | null>(null);
let preview = $state<ImportPreview | null>(null);
let working = $state(false);
onMount(() => void crmCatalogs.preload(['modo_tarifario']));
$effect(() => { const cid = companyId; if (cid) void load(cid); });
async function load(cid: number) {
loading = true;
try {
sheets = await rateSheetsAPI.list(cid, { mode: modeFilter || undefined });
if (suppliers.length === 0) suppliers = await suppliersAPI.list(cid).catch(() => []);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los tarifarios'); }
finally { loading = false; }
}
const modeLabel = (m: string) => crmCatalogs.label('modo_tarifario', m);
function openImport() {
imp = { mode: 'aereo', name: '', supplier_id: null, currency: 'USD', valid_from: '', valid_to: '', default_origin: '' };
impFile = null; preview = null; showImport = true;
}
async function dlTemplate() {
if (!companyId) return;
try { await rateSheetsAPI.downloadTemplate(imp.mode, companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo descargar la plantilla'); }
}
function onFile(e: Event) { impFile = (e.target as HTMLInputElement).files?.[0] ?? null; preview = null; }
async function doPreview() {
if (!companyId || !impFile) { toast.error('Selecciona un archivo'); return; }
working = true;
try { preview = await rateSheetsAPI.importPreview(imp.mode, impFile, companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo leer el archivo'); }
finally { working = false; }
}
async function doImport() {
if (!companyId || !impFile) return;
if (!imp.name.trim()) { toast.error('Ponle nombre al tarifario'); return; }
working = true;
try {
await rateSheetsAPI.importSheet(companyId, {
mode: imp.mode, name: imp.name.trim(), supplier_id: imp.supplier_id,
currency: imp.currency, valid_from: imp.valid_from || null, valid_to: imp.valid_to || null,
default_origin: imp.default_origin || null
}, impFile);
toast.success('Tarifario importado');
showImport = false;
await load(companyId);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo importar'); }
finally { working = false; }
}
async function remove(s: RateSheet) {
if (!companyId || !confirm(`¿Eliminar el tarifario "${s.name}"?`)) return;
try { await rateSheetsAPI.remove(s.id, companyId); toast.success('Eliminado'); await load(companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
}
const statusCls = (st: string) => st === 'activo' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: st === 'vencido' || st === 'reemplazado' ? 'bg-muted text-muted-foreground' : 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400';
</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"><FileSpreadsheet class="h-6 w-6" /> Tarifarios</h1>
<p class="mt-1 text-sm text-muted-foreground">Costos de proveedores por ruta, base de las cotizaciones.</p>
</div>
<Button onclick={openImport} disabled={!companyId}><Upload class="mr-1 h-4 w-4" /> Importar tarifario</Button>
</div>
<Card.Root>
<Card.Header>
<div class="flex flex-wrap items-center gap-3">
<select class={inputCls} bind:value={modeFilter} onchange={() => companyId && load(companyId)}>
<option value="">Todos los modos</option>
{#each crmCatalogs.options('modo_tarifario') as m (m.value)}<option value={m.value}>{m.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 sheets.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin tarifarios. Importa uno con el botón de arriba.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Nombre</Table.Head><Table.Head>Modo</Table.Head><Table.Head>Moneda</Table.Head><Table.Head>Rutas</Table.Head><Table.Head>Vigencia</Table.Head><Table.Head>Estatus</Table.Head><Table.Head class="text-right">Acciones</Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each sheets as s (s.id)}
<Table.Row>
<Table.Cell class="font-medium"><a class="hover:underline" href={`/dashboard/crm/tarifarios/${s.id}`}>{s.name}</a></Table.Cell>
<Table.Cell>{modeLabel(s.mode)}</Table.Cell>
<Table.Cell>{s.currency ?? '—'}</Table.Cell>
<Table.Cell>{s.lane_count ?? 0}</Table.Cell>
<Table.Cell class="text-xs">{s.valid_from ? formatDate(s.valid_from) : '—'}{s.valid_to ? formatDate(s.valid_to) : '—'}</Table.Cell>
<Table.Cell><span class="inline-flex rounded-full px-2 py-0.5 text-xs {statusCls(s.status)}">{s.status}</span></Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost" size="sm" href={`/dashboard/crm/tarifarios/${s.id}`}><Pencil class="mr-1 h-4 w-4" /> Abrir</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>
{#if showImport}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showImport = false)}>
<div class="max-h-[92vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3 class="mb-4 flex items-center gap-2 text-base font-semibold"><Upload class="h-4 w-4" /> Importar tarifario</h3>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Modo *</span>
<select class={inputCls} bind:value={imp.mode}>{#each crmCatalogs.options('modo_tarifario') 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">Nombre *</span><input class={inputCls} bind:value={imp.name} placeholder="Tarifario NLU 2º sem 2026" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Proveedor</span>
<select class={inputCls} bind:value={imp.supplier_id}><option value={null}>—</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">Moneda</span>
<select class={inputCls} bind:value={imp.currency}><option value="USD">USD</option><option value="MXN">MXN</option><option value="EUR">EUR</option></select>
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia desde</span><input type="date" class={inputCls} bind:value={imp.valid_from} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vigencia hasta</span><input type="date" class={inputCls} bind:value={imp.valid_to} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen por defecto</span><input class={inputCls} bind:value={imp.default_origin} placeholder="NLU" /></label>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 rounded-md border border-dashed p-3">
<Button variant="outline" size="sm" onclick={dlTemplate}><Download class="mr-1 h-4 w-4" /> Descargar plantilla</Button>
<input type="file" accept=".xlsx" class="{inputCls} flex-1" onchange={onFile} />
<Button variant="outline" size="sm" onclick={doPreview} disabled={working || !impFile}>Vista previa</Button>
</div>
{#if preview}
<div class="mt-3 rounded-md border p-3 text-sm">
<p class="mb-2">Filas válidas: <b>{preview.valid}</b> / {preview.total}. Columnas: <span class="font-mono text-xs">{preview.columns.join(', ')}</span></p>
{#if preview.rows.some((r) => r.errors.length)}
<div class="max-h-32 overflow-y-auto text-xs text-destructive">
{#each preview.rows.filter((r) => r.errors.length).slice(0, 20) as r (r.row)}<div>Fila {r.row}: {r.errors.join('; ')}</div>{/each}
</div>
{/if}
</div>
{/if}
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (showImport = false)}>Cancelar</Button>
<Button onclick={doImport} disabled={working || !impFile || preview?.valid === 0}>{working ? 'Importando…' : 'Importar'}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,253 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ArrowLeft, FileSpreadsheet, Trash2, CheckCircle2, Plus } 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 { rateSheetsAPI, type RateSheet, type RateLane, type RateCharge } from '$lib/api/crm/rates';
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const sheetId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const inputCls = 'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let sheet = $state<RateSheet | null>(null);
let lanes = $state<RateLane[]>([]);
let charges = $state<RateCharge[]>([]);
let loading = $state(false);
const isAir = $derived(sheet?.mode === 'aereo');
const isLcl = $derived(sheet?.mode === 'maritimo_lcl');
const isFcl = $derived(sheet?.mode === 'maritimo_fcl');
const usesBreaks = $derived(isAir || isLcl);
const rateUnit = $derived(sheet?.mode === 'aereo' ? 'per_kg' : sheet?.mode === 'maritimo_lcl' ? 'per_wm' : sheet?.mode === 'maritimo_fcl' ? 'per_container' : 'flat');
// alta de ruta
let showLane = $state(false);
let lf = $state({ origin: '', destination: '', region: '', equipment_type: '', min_charge: null as number | null, flat_rate: null as number | null, transit_days: null as number | null, notes: '' });
let brks = $state<{ from_qty: number | null; rate: number | null }[]>([{ from_qty: null, rate: null }]);
// alta de cargo
let cf = $state({ concept: '', charge_type: 'fijo', value: null as number | null, condition: '' });
let working = $state(false);
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo', 'concepto_cargo']));
$effect(() => { const cid = companyId, id = sheetId; if (cid && id) void load(cid, id); });
async function load(cid: number, id: number) {
loading = true;
try {
sheet = await rateSheetsAPI.get(id, cid);
[lanes, charges] = await Promise.all([rateSheetsAPI.lanes(id, cid), rateSheetsAPI.charges(id, cid)]);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el tarifario'); }
finally { loading = false; }
}
async function setStatus(status: string) {
if (!companyId || !sheet) return;
try { sheet = await rateSheetsAPI.update(sheet.id, { status }, companyId); toast.success(`Tarifario ${status}`); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo actualizar'); }
}
async function delLane(l: RateLane) {
if (!companyId || !sheet || !confirm('¿Eliminar esta ruta?')) return;
try { await rateSheetsAPI.removeLane(sheet.id, l.id, companyId); await load(companyId, sheet.id); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
}
function openLane() {
lf = { origin: sheet?.default_origin ?? '', destination: '', region: '', equipment_type: '', min_charge: null, flat_rate: null, transit_days: null, notes: '' };
brks = [{ from_qty: null, rate: null }];
showLane = true;
}
async function saveLane() {
if (!companyId || !sheet) return;
if (!lf.destination.trim()) { toast.error('Indica el destino'); return; }
const breaks = usesBreaks
? brks.filter((b) => b.rate != null).map((b) => ({ from_qty: b.from_qty ?? 0, rate: b.rate as number }))
: [];
working = true;
try {
await rateSheetsAPI.addLane(sheet.id, {
origin: lf.origin || null, destination: lf.destination, region: lf.region || null,
equipment_type: isFcl ? (lf.equipment_type || null) : null, rate_unit: rateUnit,
min_charge: usesBreaks ? lf.min_charge : null,
flat_rate: usesBreaks ? null : lf.flat_rate,
transit_days: lf.transit_days, notes: lf.notes || null, breaks
} as any, companyId);
toast.success('Ruta agregada');
showLane = false;
await load(companyId, sheet.id);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo guardar la ruta'); }
finally { working = false; }
}
async function addCharge() {
if (!companyId || !sheet) return;
if (!cf.concept) { toast.error('Elige el concepto del cargo'); return; }
working = true;
try {
await rateSheetsAPI.addCharge(sheet.id, { concept: cf.concept, charge_type: cf.charge_type, value: cf.value, condition: cf.condition || null }, companyId);
toast.success('Cargo agregado');
cf = { concept: '', charge_type: 'fijo', value: null, condition: '' };
charges = await rateSheetsAPI.charges(sheet.id, companyId);
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo agregar el cargo'); }
finally { working = false; }
}
async function delCharge(c: RateCharge) {
if (!companyId || !sheet) return;
try { await rateSheetsAPI.removeCharge(sheet.id, c.id, companyId); charges = await rateSheetsAPI.charges(sheet.id, companyId); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
}
const eq = (c: string | null) => c ? crmCatalogs.label('tipo_equipo', c) : '—';
const conceptLabel = (c: string) => crmCatalogs.label('concepto_cargo', c);
const breaksTxt = (l: RateLane) => (l.breaks ?? []).map((b) => `${b.from_qty}: ${b.rate}`).join(' · ') || '—';
const CHARGE_TYPES = [
{ v: 'fijo', l: 'Fijo' }, { v: 'por_kg', l: 'Por kg' }, { v: 'por_guia', l: 'Por guía' },
{ v: 'por_contenedor', l: 'Por contenedor' }, { v: 'porcentaje', l: '% sobre tarifa' }
];
const chargeTypeLabel = (t: string) => CHARGE_TYPES.find((x) => x.v === t)?.l ?? t;
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/tarifarios"><ArrowLeft class="mr-1 h-4 w-4" /> Tarifarios</Button>
{#if loading && !sheet}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if sheet}
<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"><FileSpreadsheet class="h-6 w-6" /> {sheet.name}</h1>
<p class="mt-1 text-sm text-muted-foreground">
{crmCatalogs.label('modo_tarifario', sheet.mode)} · {sheet.currency ?? '—'} · {lanes.length} rutas
· vigencia {sheet.valid_from ? formatDate(sheet.valid_from) : '—'}{sheet.valid_to ? formatDate(sheet.valid_to) : '—'}
· estatus <b>{sheet.status}</b>
</p>
</div>
<div class="flex gap-2">
{#if sheet.status !== 'activo'}
<Button size="sm" onclick={() => setStatus('activo')}><CheckCircle2 class="mr-1 h-4 w-4" /> Activar</Button>
{:else}
<Button size="sm" variant="outline" onclick={() => setStatus('vencido')}>Marcar vencido</Button>
{/if}
</div>
</div>
<Card.Root>
<Card.Header class="flex flex-row items-center justify-between">
<div><Card.Title class="text-base">Rutas ({lanes.length})</Card.Title>
<Card.Description>El motor de cotización usa estas rutas cuando el tarifario está <b>activo</b> y vigente.</Card.Description>
</div>
<Button size="sm" variant="outline" onclick={openLane}><Plus class="mr-1 h-4 w-4" /> Agregar ruta</Button>
</Card.Header>
<Card.Content>
{#if lanes.length === 0}
<p class="text-sm text-muted-foreground">Sin rutas. Agrega una o importa por Excel.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header><Table.Row><Table.Head>Región</Table.Head><Table.Head>Origen</Table.Head><Table.Head>Destino</Table.Head><Table.Head>Equipo</Table.Head><Table.Head>Mínimo</Table.Head><Table.Head>Tarifa / Quiebres</Table.Head><Table.Head>Tránsito</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each lanes as l (l.id)}
<Table.Row>
<Table.Cell class="text-xs">{l.region ?? '—'}</Table.Cell>
<Table.Cell>{l.origin ?? sheet.default_origin ?? '—'}</Table.Cell>
<Table.Cell class="font-medium">{l.destination ?? '—'}</Table.Cell>
<Table.Cell class="text-xs">{eq(l.equipment_type)}</Table.Cell>
<Table.Cell>{l.min_charge ?? '—'}</Table.Cell>
<Table.Cell class="text-xs">{l.flat_rate != null ? l.flat_rate : breaksTxt(l)}</Table.Cell>
<Table.Cell>{l.transit_days ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => delLane(l)} 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>
<Card.Root>
<Card.Header><Card.Title class="text-base">Cargos adicionales ({charges.length})</Card.Title>
<Card.Description>Recargos que el motor suma a la tarifa base (combustible, DGR, THC, maniobras…).</Card.Description>
</Card.Header>
<Card.Content>
<div class="mb-4 flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span>
<select class={inputCls} bind:value={cf.concept}><option value=""></option>{#each crmCatalogs.options('concepto_cargo') 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">Tipo</span>
<select class={inputCls} bind:value={cf.charge_type}>{#each CHARGE_TYPES as t (t.v)}<option value={t.v}>{t.l}</option>{/each}</select>
</label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor</span><input type="number" step="0.01" class="{inputCls} w-28" bind:value={cf.value} placeholder="monto o %" /></label>
<label class="flex flex-1 flex-col gap-1 text-sm"><span class="font-medium">Condición</span><input class={inputCls} bind:value={cf.condition} placeholder="opcional (ej. solo DGR)" /></label>
<Button size="sm" onclick={addCharge} disabled={working}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
</div>
{#if charges.length === 0}
<p class="text-sm text-muted-foreground">Sin cargos adicionales.</p>
{:else}
<Table.Root>
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Tipo</Table.Head><Table.Head>Valor</Table.Head><Table.Head>Condición</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
<Table.Body>
{#each charges as c (c.id)}
<Table.Row>
<Table.Cell class="font-medium">{conceptLabel(c.concept)}</Table.Cell>
<Table.Cell class="text-xs">{chargeTypeLabel(c.charge_type)}</Table.Cell>
<Table.Cell>{c.value ?? '—'}{c.charge_type === 'porcentaje' ? ' %' : ''}</Table.Cell>
<Table.Cell class="text-xs text-muted-foreground">{c.condition ?? '—'}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => delCharge(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}
</div>
{#if showLane && sheet}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showLane = false)}>
<div class="max-h-[92vh] w-full max-w-xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<h3 class="mb-4 text-base font-semibold">Nueva ruta</h3>
<div class="grid gap-3 sm:grid-cols-3">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={lf.origin} placeholder={sheet.default_origin ?? 'NLU'} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span><input class={inputCls} bind:value={lf.destination} placeholder="FRA / CNSHA" /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Región</span><input class={inputCls} bind:value={lf.region} /></label>
{#if isFcl}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de equipo</span>
<select class={inputCls} bind:value={lf.equipment_type}><option value=""></option>{#each crmCatalogs.options('tipo_equipo') as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select>
</label>
{/if}
{#if usesBreaks}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cargo mínimo</span><input type="number" step="0.01" class={inputCls} bind:value={lf.min_charge} /></label>
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" step="0.01" class={inputCls} bind:value={lf.flat_rate} /></label>
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tránsito (días)</span><input type="number" class={inputCls} bind:value={lf.transit_days} /></label>
</div>
{#if usesBreaks}
<div class="mt-4">
<div class="mb-1 flex items-center justify-between"><span class="text-sm font-medium">Quiebres ({isAir ? 'kg' : 'W/M'} → tarifa)</span>
<Button size="sm" variant="ghost" onclick={() => (brks = [...brks, { from_qty: null, rate: null }])}><Plus class="mr-1 h-4 w-4" /> Fila</Button>
</div>
{#each brks as b, i (i)}
<div class="mb-1 flex items-center gap-2">
<input type="number" step="0.001" class="{inputCls} w-32" bind:value={b.from_qty} placeholder="desde (100)" />
<input type="number" step="0.0001" class="{inputCls} w-32" bind:value={b.rate} placeholder="tarifa (1.00)" />
<button type="button" class="text-destructive" onclick={() => (brks = brks.filter((_, j) => j !== i))} aria-label="Quitar">×</button>
</div>
{/each}
</div>
{/if}
<label class="mt-3 flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><input class={inputCls} bind:value={lf.notes} /></label>
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
<Button variant="outline" onclick={() => (showLane = false)}>Cancelar</Button>
<Button onclick={saveLane} disabled={working}>{working ? 'Guardando…' : 'Guardar ruta'}</Button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { FileText, Upload } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { quoteSettingsAPI, type QuoteSettings } from '$lib/api/crm';
import { toast } from 'svelte-sonner';
const companyId = $derived(companyStore.activeCompany?.id ?? null);
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
let s = $state<QuoteSettings>({});
let logoUrl = $state<string | null>(null);
let loading = $state(false);
let saving = $state(false);
let uploading = $state(false);
$effect(() => { const cid = companyId; if (cid) void load(cid); });
async function load(cid: number) {
loading = true;
try {
s = await quoteSettingsAPI.get(cid);
logoUrl = (await quoteSettingsAPI.logoUrl(cid).catch(() => ({ url: null }))).url;
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar la configuración'); }
finally { loading = false; }
}
async function save() {
if (!companyId) return;
saving = true;
try { s = await quoteSettingsAPI.save(companyId, s); toast.success('Configuración guardada'); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo guardar'); }
finally { saving = false; }
}
async function onLogo(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file || !companyId) return;
uploading = true;
try {
s = await quoteSettingsAPI.uploadLogo(companyId, file);
logoUrl = (await quoteSettingsAPI.logoUrl(companyId)).url;
toast.success('Logo actualizado');
} catch (err) { toast.error(err instanceof Error ? err.message : 'No se pudo subir el logo'); }
finally { uploading = false; }
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> Formato de cotización</h1>
<p class="mt-1 text-sm text-muted-foreground">Marca y encabezados que se imprimen en el PDF de las cotizaciones (por compañía).</p>
</div>
{#if !companyId}
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
{:else}
<Card.Root>
<Card.Header><Card.Title class="text-base">Logo</Card.Title></Card.Header>
<Card.Content>
<div class="flex flex-wrap items-center gap-4">
{#if logoUrl}
<img src={logoUrl} alt="Logo" class="h-16 rounded border bg-white object-contain p-1" />
{:else}
<div class="flex h-16 w-32 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">Sin logo</div>
{/if}
<label class="cursor-pointer">
<input type="file" accept="image/*" class="hidden" onchange={onLogo} />
<span class="inline-flex items-center gap-1 rounded-md border px-3 py-2 text-sm hover:bg-muted"><Upload class="h-4 w-4" /> {uploading ? 'Subiendo…' : 'Subir logo'}</span>
</label>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header><Card.Title class="text-base">Datos del emisor</Card.Title>
<Card.Description>Aparecen en el encabezado del PDF.</Card.Description>
</Card.Header>
<Card.Content>
<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 (emisor)</span><input class={inputCls} bind:value={s.emitter_name} placeholder="Mi Agencia SA de CV" /></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={s.emitter_rfc} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Teléfono</span><input class={inputCls} bind:value={s.emitter_phone} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Correo</span><input class={inputCls} bind:value={s.emitter_email} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Sitio web</span><input class={inputCls} bind:value={s.emitter_website} /></label>
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Dirección</span><textarea rows="2" class={inputCls} bind:value={s.emitter_address}></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Color de acento</span><input type="color" class="{inputCls} h-10 p-1" bind:value={s.accent_color} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Prefijo de folio</span><input class={inputCls} maxlength="12" bind:value={s.quote_prefix} placeholder="COT" /></label>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header><Card.Title class="text-base">Textos por defecto</Card.Title></Card.Header>
<Card.Content>
<div class="grid gap-4">
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Condiciones comerciales (por defecto)</span><textarea rows="5" class={inputCls} bind:value={s.default_terms} placeholder="Una condición por línea…"></textarea></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Pie de página</span><input class={inputCls} bind:value={s.footer_note} /></label>
</div>
</Card.Content>
</Card.Root>
<div class="flex justify-end"><Button onclick={save} disabled={saving || loading}>{saving ? 'Guardando…' : 'Guardar configuración'}</Button></div>
{/if}
</div>