Compare commits
27 Commits
223395b430
...
feature/cr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87d23b3d23 | ||
|
|
e269e46d88 | ||
|
|
03055cd377 | ||
|
|
e09cb02b8b | ||
|
|
206ff450f8 | ||
|
|
ef7e69ed57 | ||
|
|
fa542ddf18 | ||
|
|
2ae6901b6a | ||
|
|
8576ec7e37 | ||
|
|
de8557dec2 | ||
|
|
a2a474f8c8 | ||
|
|
8aef99df9c | ||
|
|
5f9c7cf000 | ||
|
|
71be225b33 | ||
|
|
0cffd351df | ||
|
|
a14acf59e5 | ||
|
|
0d8c4ddf62 | ||
|
|
de9e35c501 | ||
|
|
868724d1f2 | ||
|
|
cd3f8c62a4 | ||
|
|
387b3c0e78 | ||
|
|
706da34f4a | ||
|
|
ce8b042e84 | ||
|
|
63ad2e2ecd | ||
|
|
0b6848bbdd | ||
|
|
29170f7c8c | ||
|
|
b76d42be83 |
@@ -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)
|
||||
90
backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py
Normal file
90
backend/alembic/versions/e6f7a8b9c0d1_crm_catalog_items.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: d5e6f7a8b9c0
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores):
|
||||
catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes
|
||||
(observaciones comerciales, "otro" de medio de contacto y de clasificación).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e6f7a8b9c0d1"
|
||||
down_revision: Union[str, None] = "d5e6f7a8b9c0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ----- crm.catalog_items -----
|
||||
op.create_table(
|
||||
"catalog_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("catalog", sa.String(length=60), nullable=False),
|
||||
sa.Column("code", sa.String(length=64), nullable=False),
|
||||
sa.Column("label", sa.String(length=255), nullable=False),
|
||||
sa.Column("parent_catalog", sa.String(length=60), nullable=True),
|
||||
sa.Column("parent_code", sa.String(length=64), nullable=True),
|
||||
# NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant.
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("extra", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema=SCHEMA,
|
||||
)
|
||||
op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA)
|
||||
op.create_index(
|
||||
"ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA
|
||||
)
|
||||
# Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas.
|
||||
op.create_index(
|
||||
"uq_crm_catalog_items_global",
|
||||
"catalog_items",
|
||||
["catalog", "code"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("tenant_id IS NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_crm_catalog_items_tenant",
|
||||
"catalog_items",
|
||||
["catalog", "code", "tenant_id"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("tenant_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
# ----- columnas nuevas -----
|
||||
# Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto.
|
||||
op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA)
|
||||
op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA)
|
||||
# Proveedores: "otro" de la clasificación múltiple.
|
||||
op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("suppliers", "classification_other", schema=SCHEMA)
|
||||
op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA)
|
||||
op.drop_column("accounts", "commercial_observations", schema=SCHEMA)
|
||||
|
||||
op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_table("catalog_items", schema=SCHEMA)
|
||||
@@ -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,
|
||||
)
|
||||
131
backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py
Normal file
131
backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py
Normal 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)
|
||||
@@ -24,6 +24,12 @@ from .dto import (
|
||||
)
|
||||
from .service import AuthService
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -431,32 +437,229 @@ async def get_my_companies(
|
||||
_ensure_user_tenant_for_company,
|
||||
)
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from sqlalchemy import text
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
if not tenant_id:
|
||||
return []
|
||||
|
||||
tenant = db.query(Tenant).filter(Tenant.id == int(tenant_id)).first()
|
||||
name = (
|
||||
(tenant.name if tenant else None)
|
||||
or current_user.get("tenant_slug")
|
||||
or "Mi empresa"
|
||||
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
|
||||
# defecto en el primer acceso y AUTO-LIGA al usuario a TODAS las compañías de
|
||||
# su tenant. Así cualquier usuario del mismo tenant (misma organización del
|
||||
# Workspace) entra y ve la(s) compañía(s) sin gestión manual. El ROL no se
|
||||
# asigna aquí: es solo membresía; los permisos se otorgan aparte (un admin
|
||||
# asigna el rol; el primer usuario recibe super_admin vía /permissions/me).
|
||||
if tenant_id:
|
||||
tenant_id = int(tenant_id)
|
||||
company_ids = [
|
||||
int(r[0])
|
||||
for r in db.execute(
|
||||
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
|
||||
{"tid": tenant_id},
|
||||
).fetchall()
|
||||
]
|
||||
if not company_ids:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
default_name = (
|
||||
(tenant.name if tenant else None)
|
||||
or current_user.get("tenant_slug")
|
||||
or "Mi empresa"
|
||||
)
|
||||
created = db.execute(
|
||||
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
|
||||
{"tid": tenant_id, "name": default_name},
|
||||
).fetchone()
|
||||
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
company_ids = [int(created[0])]
|
||||
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
|
||||
|
||||
# Auto-ligado por tenant (solo membresía, sin rol).
|
||||
if user_id:
|
||||
for cid in company_ids:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid)
|
||||
except Exception as exc:
|
||||
logger.warning("auto-ligado de compañía %s falló (no bloquea): %s", cid, exc)
|
||||
|
||||
# 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona
|
||||
# también para hub_admin sin tenant en el token: verá las compañías que creó
|
||||
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
|
||||
if not user_id:
|
||||
return []
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug
|
||||
FROM a76.company c
|
||||
LEFT JOIN core.tenants t ON t.id = c.tenant_id
|
||||
WHERE c.id IN (
|
||||
SELECT company_id FROM core.user_tenants
|
||||
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
|
||||
UNION
|
||||
SELECT company_id FROM core.user_company_roles
|
||||
WHERE user_id = :uid AND is_active
|
||||
)
|
||||
ORDER BY c.id
|
||||
"""
|
||||
),
|
||||
{"uid": str(user_id)},
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": int(r[0]),
|
||||
"name": r[1] or "Empresa",
|
||||
"tenant_id": int(r[4]),
|
||||
"tenant_name": r[5],
|
||||
"tenant_slug": r[6],
|
||||
"rfc": r[2],
|
||||
"logo": r[3],
|
||||
"is_active": True,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class _CreateCompanyDTO(BaseModel):
|
||||
name: str
|
||||
tenant_id: int
|
||||
rfc: Optional[str] = None
|
||||
|
||||
|
||||
async def _sync_tenants_from_hub(request: Request, db: Session) -> None:
|
||||
"""
|
||||
Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y
|
||||
los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los
|
||||
tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías.
|
||||
Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no
|
||||
responde, no bloquea (se devuelven los tenants ya sincronizados).
|
||||
"""
|
||||
import httpx
|
||||
from sqlalchemy import text as _text
|
||||
from core.config import settings
|
||||
from core import session_store
|
||||
from api.v1.modules.core.tenants.models import Tenant, TenantType
|
||||
|
||||
sid = request.cookies.get("crm_sid") if request else None
|
||||
kc_token = None
|
||||
if sid:
|
||||
sess = session_store.get_session(sid)
|
||||
kc_token = (sess or {}).get("access_token")
|
||||
if not kc_token:
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/tenants",
|
||||
headers={"Authorization": f"Bearer {kc_token}"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code)
|
||||
return
|
||||
payload = r.json()
|
||||
items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or [])
|
||||
for t in items:
|
||||
tid = t.get("id")
|
||||
if tid is None:
|
||||
continue
|
||||
name = t.get("name") or t.get("display_name") or t.get("slug")
|
||||
slug = t.get("slug") or f"tenant-{tid}"
|
||||
existing = db.query(Tenant).filter(Tenant.id == int(tid)).first()
|
||||
if existing:
|
||||
if name and existing.name != name:
|
||||
existing.name = name
|
||||
else:
|
||||
db.add(Tenant(
|
||||
id=int(tid), name=name or slug, slug=slug,
|
||||
keycloak_realm=slug, type=TenantType.SHARED, is_active=True,
|
||||
))
|
||||
db.commit()
|
||||
db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))"))
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/assignable-tenants")
|
||||
async def assignable_tenants(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace;
|
||||
aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario
|
||||
con tenant ve el suyo.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
|
||||
|
||||
if is_hub_admin(current_user):
|
||||
# Sincroniza automáticamente los tenants del Workspace antes de listar.
|
||||
await _sync_tenants_from_hub(request, db)
|
||||
rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712
|
||||
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows]
|
||||
|
||||
tid = resolve_effective_tenant_id_from_user(current_user)
|
||||
if tid:
|
||||
t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712
|
||||
return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else []
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/companies", status_code=201)
|
||||
async def create_company(
|
||||
data: _CreateCompanyDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
|
||||
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
|
||||
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import (
|
||||
resolve_effective_tenant_id_from_user,
|
||||
is_hub_admin,
|
||||
_ensure_user_tenant_for_company,
|
||||
)
|
||||
|
||||
# Garantizar el vínculo usuario↔tenant↔company (company_id = tenant_id).
|
||||
name = (data.name or "").strip()
|
||||
if len(name) < 2:
|
||||
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
|
||||
|
||||
tid = int(data.tenant_id)
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
|
||||
|
||||
# Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant;
|
||||
# un usuario ligado a un tenant, solo en el suyo.
|
||||
if not is_hub_admin(current_user):
|
||||
own = resolve_effective_tenant_id_from_user(current_user)
|
||||
if own is None or int(own) != tid:
|
||||
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
|
||||
|
||||
created = db.execute(
|
||||
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
|
||||
{"t": tid, "n": name, "r": (data.rfc or None)},
|
||||
).fetchone()
|
||||
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
cid = int(created[0])
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if user_id:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), int(tenant_id), int(tenant_id))
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
|
||||
except Exception as exc:
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
|
||||
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
|
||||
|
||||
return [{
|
||||
"id": int(tenant_id),
|
||||
"name": name,
|
||||
"tenant_id": int(tenant_id),
|
||||
"rfc": None,
|
||||
"logo": None,
|
||||
"is_active": True,
|
||||
}]
|
||||
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}
|
||||
|
||||
@@ -235,7 +235,7 @@ class AuthService:
|
||||
return claims
|
||||
|
||||
def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Construye los claims de la sesión local a partir de la respuesta KC del Hub."""
|
||||
"""Construye los claims de la sesión local a partir del token KC (decode)."""
|
||||
kc_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||
claims: Dict[str, Any] = dict(kc_claims)
|
||||
# tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token
|
||||
@@ -245,6 +245,33 @@ class AuthService:
|
||||
claims["tenant_slug"] = data.get("tenant_slug")
|
||||
return claims
|
||||
|
||||
async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae
|
||||
is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no
|
||||
responde, se cae al decode del token KC. Así la sesión local sabe si el
|
||||
usuario es hub_admin sin volver a consultar al Hub en cada request.
|
||||
"""
|
||||
from core.security import verify_token
|
||||
|
||||
claims: Dict[str, Any] = {}
|
||||
try:
|
||||
info = await verify_token(data.get("access_token", ""))
|
||||
if isinstance(info, dict):
|
||||
claims = dict(info)
|
||||
except Exception as exc:
|
||||
logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc)
|
||||
|
||||
if not claims:
|
||||
return self._session_claims_from_kc(data)
|
||||
|
||||
# tenant_id/tenant_slug explícitos del Hub tienen precedencia.
|
||||
if data.get("tenant_id") is not None:
|
||||
claims["tenant_id"] = data.get("tenant_id")
|
||||
if data.get("tenant_slug") is not None:
|
||||
claims["tenant_slug"] = data.get("tenant_slug")
|
||||
return claims
|
||||
|
||||
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
|
||||
"""
|
||||
Refresca la sesión.
|
||||
@@ -321,7 +348,7 @@ class AuthService:
|
||||
from core import local_session, session_store
|
||||
|
||||
start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp())
|
||||
claims = self._session_claims_from_kc(data)
|
||||
claims = await self._session_claims(data)
|
||||
new_access = data.get("access_token", "")
|
||||
new_refresh = data.get("refresh_token", "")
|
||||
# Reutiliza la sesión de valkey si ya existía; si no, la crea.
|
||||
|
||||
@@ -37,13 +37,33 @@ async def create_invite(
|
||||
required_permissions=["user.create"],
|
||||
)
|
||||
|
||||
# tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el
|
||||
# token), se resuelve desde la compañía destino (a76.company → core.tenants).
|
||||
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||
if not tenant_slug:
|
||||
from sqlalchemy import text as _text
|
||||
row = db.execute(
|
||||
_text(
|
||||
"SELECT t.slug FROM a76.company c "
|
||||
"JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c"
|
||||
),
|
||||
{"c": data.company_id},
|
||||
).first()
|
||||
if row and row[0]:
|
||||
tenant_slug = row[0]
|
||||
|
||||
created_by: str = current_user.get("sub") or ""
|
||||
|
||||
# El invite se crea en el Hub: se necesita el token KC (la sesión local no la
|
||||
# acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta.
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
kc_token = await get_hub_access_token(request)
|
||||
|
||||
service = InviteService(db)
|
||||
return await service.create_invite(
|
||||
data=data,
|
||||
created_by=created_by,
|
||||
tenant_slug=tenant_slug,
|
||||
user_access_token=credentials.credentials,
|
||||
user_access_token=kc_token or credentials.credentials,
|
||||
)
|
||||
|
||||
@@ -53,12 +53,17 @@ async def get_user_statistics(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
kc_token = await get_hub_access_token(request)
|
||||
if kc_token:
|
||||
token = kc_token
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
@@ -84,12 +89,19 @@ async def list_users(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
# El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta.
|
||||
# Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado.
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
kc_token = await get_hub_access_token(request)
|
||||
if kc_token:
|
||||
token = kc_token
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
|
||||
@@ -18,10 +18,12 @@ class AccountBase(BaseModel):
|
||||
# Comercial
|
||||
commercial_classification: str | None = Field(None, max_length=20)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
preferred_contact_other: str | None = Field(None, max_length=120)
|
||||
language: str | None = Field(None, max_length=40)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
commercial_observations: str | None = None # observaciones generales
|
||||
# Fiscal
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
@@ -59,10 +61,12 @@ class AccountUpdate(BaseModel):
|
||||
status: str | None = Field(None, max_length=20)
|
||||
commercial_classification: str | None = Field(None, max_length=20)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
preferred_contact_other: str | None = Field(None, max_length=120)
|
||||
language: str | None = Field(None, max_length=40)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
commercial_observations: str | None = None
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
cfdi_use: str | None = Field(None, max_length=60)
|
||||
payment_method: str | None = Field(None, max_length=60)
|
||||
|
||||
@@ -39,12 +39,15 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
# ----- Información comercial -----
|
||||
# Clasificación: importador | exportador | ambos
|
||||
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
|
||||
# Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro
|
||||
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Texto libre cuando el medio de contacto es "otro"
|
||||
preferred_contact_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
|
||||
|
||||
# ----- Información fiscal -----
|
||||
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
|
||||
|
||||
@@ -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"))
|
||||
|
||||
46
backend/api/v1/modules/crm/catalogs/dto.py
Normal file
46
backend/api/v1/modules/crm/catalogs/dto.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Schemas (DTOs) de los catálogos de referencia del CRM."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CatalogItemBase(BaseModel):
|
||||
code: str = Field(..., max_length=64)
|
||||
label: str = Field(..., max_length=255)
|
||||
parent_catalog: str | None = Field(None, max_length=60)
|
||||
parent_code: str | None = Field(None, max_length=64)
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CatalogItemCreate(CatalogItemBase):
|
||||
pass
|
||||
|
||||
|
||||
class CatalogItemUpdate(BaseModel):
|
||||
"""PATCH: todos los campos opcionales."""
|
||||
|
||||
code: str | None = Field(None, max_length=64)
|
||||
label: str | None = Field(None, max_length=255)
|
||||
parent_code: str | None = Field(None, max_length=64)
|
||||
sort_order: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class CatalogItemResponse(CatalogItemBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
catalog: str
|
||||
tenant_id: int | None
|
||||
is_system: bool
|
||||
extra: dict | None = None # metadata (ej. dimensiones de un tipo de equipo)
|
||||
|
||||
|
||||
class CatalogMeta(BaseModel):
|
||||
"""Metadata de un catálogo para la pantalla de administración."""
|
||||
|
||||
catalog: str
|
||||
label: str
|
||||
scope: str # 'global' | 'tenant'
|
||||
is_system: bool
|
||||
count: int
|
||||
54
backend/api/v1/modules/crm/catalogs/models.py
Normal file
54
backend/api/v1/modules/crm/catalogs/models.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Modelo de catálogos de referencia del CRM (T2026-07-081/082).
|
||||
|
||||
Un único modelo genérico ``CatalogItem`` respalda todos los catálogos
|
||||
(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo
|
||||
(``catalog``) e identifica una opción por ``code`` (clave) + ``label``
|
||||
(descripción que se visualiza).
|
||||
|
||||
Alcance:
|
||||
- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos.
|
||||
- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra).
|
||||
|
||||
Los catálogos dependientes (p. ej. Estado depende de País) usan
|
||||
``parent_catalog`` + ``parent_code`` para filtrarse.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class CatalogItem(Base):
|
||||
__tablename__ = "catalog_items"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Dependencia (Estado→País, Municipio→Estado, …)
|
||||
parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente).
|
||||
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
# Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar).
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
extra: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10)."""
|
||||
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales
|
||||
(Aduanasoft) y catálogos propios de cada cliente (tenant).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
@@ -8,7 +12,9 @@ from core.security import get_current_user
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..suppliers.models import Supplier
|
||||
from . import service as catalog_service
|
||||
from .data import INCOTERMS, PARTICIPANT_ROLES
|
||||
from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -31,6 +37,76 @@ def list_participant_roles(
|
||||
return PARTICIPANT_ROLES
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Catálogos de referencia (CRUD) — T2026-07-081/082
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/catalogs", response_model=list[CatalogMeta])
|
||||
def list_catalog_meta(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Lista los catálogos disponibles (global + del tenant) con su conteo."""
|
||||
return catalog_service.list_meta(db, current_user["tenant_id"])
|
||||
|
||||
|
||||
@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse])
|
||||
def list_catalog_items(
|
||||
catalog: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"),
|
||||
include_inactive: bool = Query(False),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Opciones de un catálogo (global + del tenant), activas y ordenadas."""
|
||||
return catalog_service.list_items(
|
||||
db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_catalog_item(
|
||||
catalog: str,
|
||||
data: CatalogItemCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Inserta una opción en un catálogo."""
|
||||
return catalog_service.create_item(db, catalog, data, current_user, scope=scope)
|
||||
|
||||
|
||||
@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse)
|
||||
def update_catalog_item(
|
||||
catalog: str,
|
||||
item_id: int,
|
||||
data: CatalogItemUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Edita una opción de catálogo."""
|
||||
return catalog_service.update_item(db, catalog, item_id, data, current_user)
|
||||
|
||||
|
||||
@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_catalog_item(
|
||||
catalog: str,
|
||||
item_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Borra una opción de catálogo (los catálogos base del sistema no se borran)."""
|
||||
catalog_service.delete_item(db, catalog, item_id, current_user)
|
||||
|
||||
|
||||
@router.get("/participants")
|
||||
def list_participants(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
|
||||
69
backend/api/v1/modules/crm/catalogs/seed.py
Normal file
69
backend/api/v1/modules/crm/catalogs/seed.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Siembra de catálogos globales (Aduanasoft) del CRM.
|
||||
|
||||
Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se
|
||||
puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno:
|
||||
|
||||
docker compose exec backend python -m api.v1.modules.crm.catalogs.seed
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import CatalogItem
|
||||
from .seed_data import GLOBAL_CATALOGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_global_catalogs(db: Session) -> dict:
|
||||
"""Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}."""
|
||||
summary: dict[str, int] = {}
|
||||
for catalog, meta in GLOBAL_CATALOGS.items():
|
||||
is_system = bool(meta.get("is_system", False))
|
||||
existing = {
|
||||
row.code
|
||||
for row in db.query(CatalogItem.code).filter(
|
||||
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
|
||||
)
|
||||
}
|
||||
added = 0
|
||||
for order, item in enumerate(meta["items"]):
|
||||
if item["code"] in existing:
|
||||
continue
|
||||
db.add(
|
||||
CatalogItem(
|
||||
catalog=catalog,
|
||||
code=item["code"],
|
||||
label=item["label"],
|
||||
parent_catalog=item.get("parent_catalog"),
|
||||
parent_code=item.get("parent_code"),
|
||||
extra=item.get("extra"),
|
||||
tenant_id=None,
|
||||
sort_order=order,
|
||||
is_active=True,
|
||||
is_system=is_system,
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
if added:
|
||||
summary[catalog] = added
|
||||
db.commit()
|
||||
total = sum(summary.values())
|
||||
logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary))
|
||||
return summary
|
||||
|
||||
|
||||
def _run() -> None:
|
||||
from core.database import CoreSessionLocal
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
result = seed_global_catalogs(db)
|
||||
print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run()
|
||||
768
backend/api/v1/modules/crm/catalogs/seed_data.py
Normal file
768
backend/api/v1/modules/crm/catalogs/seed_data.py
Normal file
@@ -0,0 +1,768 @@
|
||||
"""Datos semilla de los catálogos de referencia del CRM.
|
||||
|
||||
SAT/ISO + estándar + Medidas de Equipos (tipo_equipo con dimensiones en extra)
|
||||
+ catálogos del módulo Tarifario. Globales con tenant_id NULL.
|
||||
"""
|
||||
|
||||
GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'cliente', 'label': 'Cliente'}, {'code': 'prospecto', 'label': 'Prospecto'}]},
|
||||
'tipo_persona': {'label': 'Tipo de persona',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'fisica', 'label': 'Persona física'},
|
||||
{'code': 'moral', 'label': 'Persona moral'}]},
|
||||
'estatus': {'label': 'Estatus',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'active', 'label': 'Activo'}, {'code': 'inactive', 'label': 'Inactivo'}]},
|
||||
'giro': {'label': 'Giro o industria',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'importadora', 'label': 'Importadora'},
|
||||
{'code': 'exportadora', 'label': 'Exportadora'},
|
||||
{'code': 'manufactura', 'label': 'Manufactura'},
|
||||
{'code': 'comercializadora', 'label': 'Comercializadora'},
|
||||
{'code': 'logistica', 'label': 'Logística y transporte'},
|
||||
{'code': 'agencia_aduanal', 'label': 'Agencia aduanal'},
|
||||
{'code': 'maquiladora', 'label': 'Maquiladora / IMMEX'},
|
||||
{'code': 'servicios', 'label': 'Servicios'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'clasificacion_cliente': {'label': 'Clasificación del cliente',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'importador', 'label': 'Importador'},
|
||||
{'code': 'exportador', 'label': 'Exportador'},
|
||||
{'code': 'importador_exportador', 'label': 'Importador/Exportador'}]},
|
||||
'medio_contacto': {'label': 'Medio de contacto preferido',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'llamada', 'label': 'Llamada telefónica'},
|
||||
{'code': 'correo', 'label': 'Correo electrónico'},
|
||||
{'code': 'videoconferencia', 'label': 'Videoconferencia'},
|
||||
{'code': 'whatsapp', 'label': 'WhatsApp'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'idioma': {'label': 'Idioma',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'es', 'label': 'Español'},
|
||||
{'code': 'en', 'label': 'Inglés'},
|
||||
{'code': 'zh', 'label': 'Chino (mandarín)'},
|
||||
{'code': 'pt', 'label': 'Portugués'},
|
||||
{'code': 'fr', 'label': 'Francés'},
|
||||
{'code': 'de', 'label': 'Alemán'},
|
||||
{'code': 'ja', 'label': 'Japonés'},
|
||||
{'code': 'ko', 'label': 'Coreano'},
|
||||
{'code': 'it', 'label': 'Italiano'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'regimen_fiscal': {'label': 'Régimen fiscal',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'fisica', 'label': 'Persona física'},
|
||||
{'code': 'moral', 'label': 'Persona moral'}]},
|
||||
'uso_cfdi': {'label': 'Uso de CFDI (SAT)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'G01', 'label': 'Adquisición de mercancías'},
|
||||
{'code': 'G02', 'label': 'Devoluciones, descuentos o bonificaciones'},
|
||||
{'code': 'G03', 'label': 'Gastos en general'},
|
||||
{'code': 'I01', 'label': 'Construcciones'},
|
||||
{'code': 'I02', 'label': 'Mobiliario y equipo de oficina por inversiones'},
|
||||
{'code': 'I03', 'label': 'Equipo de transporte'},
|
||||
{'code': 'I04', 'label': 'Equipo de cómputo y accesorios'},
|
||||
{'code': 'I05', 'label': 'Dados, troqueles, moldes, matrices y herramental'},
|
||||
{'code': 'I06', 'label': 'Comunicaciones telefónicas'},
|
||||
{'code': 'I07', 'label': 'Comunicaciones satelitales'},
|
||||
{'code': 'I08', 'label': 'Otra maquinaria y equipo'},
|
||||
{'code': 'D01', 'label': 'Honorarios médicos, dentales y gastos hospitalarios'},
|
||||
{'code': 'D02', 'label': 'Gastos médicos por incapacidad o discapacidad'},
|
||||
{'code': 'D03', 'label': 'Gastos funerales'},
|
||||
{'code': 'D04', 'label': 'Donativos'},
|
||||
{'code': 'D05', 'label': 'Intereses por créditos hipotecarios'},
|
||||
{'code': 'D06', 'label': 'Aportaciones voluntarias al SAR'},
|
||||
{'code': 'D07', 'label': 'Primas por seguros de gastos médicos'},
|
||||
{'code': 'D08', 'label': 'Gastos de transportación escolar obligatoria'},
|
||||
{'code': 'D09', 'label': 'Depósitos en cuentas para el ahorro'},
|
||||
{'code': 'D10', 'label': 'Pagos por servicios educativos (colegiaturas)'},
|
||||
{'code': 'S01', 'label': 'Sin efectos fiscales'},
|
||||
{'code': 'CP01', 'label': 'Pagos'},
|
||||
{'code': 'CN01', 'label': 'Nómina'},
|
||||
{'code': 'P01', 'label': 'Por definir'}]},
|
||||
'forma_pago': {'label': 'Forma de pago (SAT)',
|
||||
'is_system': True,
|
||||
'items': [{'code': '1', 'label': 'Efectivo'},
|
||||
{'code': '2', 'label': 'Cheque nominativo'},
|
||||
{'code': '3', 'label': 'Transferencia electrónica de fondos'},
|
||||
{'code': '4', 'label': 'Tarjeta de crédito'},
|
||||
{'code': '5', 'label': 'Monedero electrónico'},
|
||||
{'code': '6', 'label': 'Dinero electrónico'},
|
||||
{'code': '8', 'label': 'Vales de despensa'},
|
||||
{'code': '12', 'label': 'Dación en pago'},
|
||||
{'code': '13', 'label': 'Pago por subrogación'},
|
||||
{'code': '14', 'label': 'Pago por consignación'},
|
||||
{'code': '15', 'label': 'Condonación'},
|
||||
{'code': '17', 'label': 'Compensación'},
|
||||
{'code': '23', 'label': 'Novación'},
|
||||
{'code': '24', 'label': 'Confusión'},
|
||||
{'code': '25', 'label': 'Remisión de deuda'},
|
||||
{'code': '26', 'label': 'Prescripción o caducidad'},
|
||||
{'code': '27', 'label': 'A satisfacción del acreedor'},
|
||||
{'code': '28', 'label': 'Tarjeta de débito'},
|
||||
{'code': '29', 'label': 'Tarjeta de servicios'},
|
||||
{'code': '30', 'label': 'Aplicación de anticipos'},
|
||||
{'code': '31', 'label': 'Intermediario pagos'},
|
||||
{'code': '99', 'label': 'Por definir'}]},
|
||||
'metodo_pago': {'label': 'Método de pago (SAT)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'PPD', 'label': 'Pago en parcialidades o diferido'},
|
||||
{'code': 'PUE', 'label': 'Pago en una sola exhibición'}]},
|
||||
'moneda': {'label': 'Moneda (ISO 4217)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'CRC', 'label': 'Colón costarricense'},
|
||||
{'code': 'CUC', 'label': 'Peso Convertible'},
|
||||
{'code': 'CUP', 'label': 'Peso Cubano'},
|
||||
{'code': 'CVE', 'label': 'Cabo Verde Escudo'},
|
||||
{'code': 'CZK', 'label': 'Corona checa'},
|
||||
{'code': 'DJF', 'label': 'Franco de Djibouti'},
|
||||
{'code': 'DKK', 'label': 'Corona danesa'},
|
||||
{'code': 'DOP', 'label': 'Peso Dominicano'},
|
||||
{'code': 'DZD', 'label': 'Dinar argelino'},
|
||||
{'code': 'EGP', 'label': 'Libra egipcia'},
|
||||
{'code': 'ERN', 'label': 'Nakfa'},
|
||||
{'code': 'ETB', 'label': 'Birr etíope'},
|
||||
{'code': 'EUR', 'label': 'Euro'},
|
||||
{'code': 'FJD', 'label': 'Dólar de Fiji'},
|
||||
{'code': 'FKP', 'label': 'Libra malvinense'},
|
||||
{'code': 'GBP', 'label': 'Libra Esterlina'},
|
||||
{'code': 'GEL', 'label': 'Lari'},
|
||||
{'code': 'GHS', 'label': 'Cedi de Ghana'},
|
||||
{'code': 'GIP', 'label': 'Libra de Gibraltar'},
|
||||
{'code': 'GMD', 'label': 'Dalasi'},
|
||||
{'code': 'GNF', 'label': 'Franco guineano'},
|
||||
{'code': 'GTQ', 'label': 'Quetzal'},
|
||||
{'code': 'GYD', 'label': 'Dólar guyanés'},
|
||||
{'code': 'HKD', 'label': 'Dolar De Hong Kong'},
|
||||
{'code': 'HNL', 'label': 'Lempira'},
|
||||
{'code': 'HRK', 'label': 'Kuna'},
|
||||
{'code': 'HTG', 'label': 'Gourde'},
|
||||
{'code': 'HUF', 'label': 'Florín'},
|
||||
{'code': 'IDR', 'label': 'Rupia'},
|
||||
{'code': 'ILS', 'label': 'Nuevo Shekel Israelí'},
|
||||
{'code': 'INR', 'label': 'Rupia india'},
|
||||
{'code': 'IQD', 'label': 'Dinar iraquí'},
|
||||
{'code': 'IRR', 'label': 'Rial iraní'},
|
||||
{'code': 'ISK', 'label': 'Corona islandesa'},
|
||||
{'code': 'JMD', 'label': 'Dólar Jamaiquino'},
|
||||
{'code': 'JOD', 'label': 'Dinar jordano'},
|
||||
{'code': 'JPY', 'label': 'Yen'},
|
||||
{'code': 'KES', 'label': 'Chelín keniano'},
|
||||
{'code': 'KGS', 'label': 'Som'},
|
||||
{'code': 'KHR', 'label': 'Riel'},
|
||||
{'code': 'KMF', 'label': 'Franco Comoro'},
|
||||
{'code': 'KPW', 'label': 'Corea del Norte ganó'},
|
||||
{'code': 'KRW', 'label': 'Won'},
|
||||
{'code': 'KWD', 'label': 'Dinar kuwaití'},
|
||||
{'code': 'KYD', 'label': 'Dólar de las Islas Caimán'},
|
||||
{'code': 'KZT', 'label': 'Tenge'},
|
||||
{'code': 'LAK', 'label': 'Kip'},
|
||||
{'code': 'LBP', 'label': 'Libra libanesa'},
|
||||
{'code': 'LKR', 'label': 'Rupia de Sri Lanka'},
|
||||
{'code': 'LRD', 'label': 'Dólar liberiano'},
|
||||
{'code': 'LSL', 'label': 'Loti'},
|
||||
{'code': 'LYD', 'label': 'Dinar libio'},
|
||||
{'code': 'MAD', 'label': 'Dirham marroquí'},
|
||||
{'code': 'MDL', 'label': 'Leu moldavo'},
|
||||
{'code': 'MGA', 'label': 'Ariary malgache'},
|
||||
{'code': 'MKD', 'label': 'Denar'},
|
||||
{'code': 'MMK', 'label': 'Kyat'},
|
||||
{'code': 'MNT', 'label': 'Tugrik'},
|
||||
{'code': 'MOP', 'label': 'Pataca'},
|
||||
{'code': 'MRO', 'label': 'Ouguiya'},
|
||||
{'code': 'MUR', 'label': 'Rupia de Mauricio'},
|
||||
{'code': 'MVR', 'label': 'Rupia'},
|
||||
{'code': 'MWK', 'label': 'Kwacha'},
|
||||
{'code': 'MXN', 'label': 'Peso Mexicano'},
|
||||
{'code': 'MXV', 'label': 'México Unidad de Inversión (UDI)'},
|
||||
{'code': 'MYR', 'label': 'Ringgit malayo'},
|
||||
{'code': 'MZN', 'label': 'Mozambique Metical'},
|
||||
{'code': 'NAD', 'label': 'Dólar de Namibia'},
|
||||
{'code': 'NGN', 'label': 'Naira'},
|
||||
{'code': 'NIO', 'label': 'Córdoba Oro'},
|
||||
{'code': 'NOK', 'label': 'Corona noruega'},
|
||||
{'code': 'NPR', 'label': 'Rupia nepalí'},
|
||||
{'code': 'NZD', 'label': 'Dólar de Nueva Zelanda'},
|
||||
{'code': 'OMR', 'label': 'Rial omaní'},
|
||||
{'code': 'PAB', 'label': 'Balboa'},
|
||||
{'code': 'PEN', 'label': 'Nuevo Sol'},
|
||||
{'code': 'PGK', 'label': 'Kina'},
|
||||
{'code': 'PHP', 'label': 'Peso filipino'},
|
||||
{'code': 'PKR', 'label': 'Rupia de Pakistán'},
|
||||
{'code': 'PLN', 'label': 'Zloty'},
|
||||
{'code': 'PYG', 'label': 'Guaraní'},
|
||||
{'code': 'QAR', 'label': 'Qatar Rial'},
|
||||
{'code': 'RON', 'label': 'Leu rumano'},
|
||||
{'code': 'RSD', 'label': 'Dinar serbio'},
|
||||
{'code': 'RUB', 'label': 'Rublo ruso'},
|
||||
{'code': 'RWF', 'label': 'Franco ruandés'},
|
||||
{'code': 'SAR', 'label': 'Riyal saudí'},
|
||||
{'code': 'SBD', 'label': 'Dólar de las Islas Salomón'},
|
||||
{'code': 'SCR', 'label': 'Rupia de Seychelles'},
|
||||
{'code': 'SDG', 'label': 'Libra sudanesa'},
|
||||
{'code': 'SEK', 'label': 'Corona sueca'},
|
||||
{'code': 'SGD', 'label': 'Dolar De Singapur'},
|
||||
{'code': 'SHP', 'label': 'Libra de Santa Helena'},
|
||||
{'code': 'SLL', 'label': 'Leona'},
|
||||
{'code': 'SOS', 'label': 'Chelín somalí'},
|
||||
{'code': 'SRD', 'label': 'Dólar de Suriname'},
|
||||
{'code': 'SSP', 'label': 'Libra sudanesa Sur'},
|
||||
{'code': 'STD', 'label': 'Dobra'},
|
||||
{'code': 'SVC', 'label': 'Colon El Salvador'},
|
||||
{'code': 'SYP', 'label': 'Libra Siria'},
|
||||
{'code': 'SZL', 'label': 'Lilangeni'},
|
||||
{'code': 'THB', 'label': 'Baht'},
|
||||
{'code': 'TJS', 'label': 'Somoni'},
|
||||
{'code': 'TMT', 'label': 'Turkmenistán nuevo manat'},
|
||||
{'code': 'TND', 'label': 'Dinar tunecino'},
|
||||
{'code': 'TOP', 'label': "Pa'anga"},
|
||||
{'code': 'TRY', 'label': 'Lira turca'},
|
||||
{'code': 'TTD', 'label': 'Dólar de Trinidad y Tobago'},
|
||||
{'code': 'TWD', 'label': 'Nuevo dólar de Taiwán'},
|
||||
{'code': 'TZS', 'label': 'Shilling tanzano'},
|
||||
{'code': 'UAH', 'label': 'Hryvnia'},
|
||||
{'code': 'UGX', 'label': 'Shilling de Uganda'},
|
||||
{'code': 'USD', 'label': 'Dolar americano'},
|
||||
{'code': 'USN', 'label': 'Dólar estadounidense (día siguiente)'},
|
||||
{'code': 'UYI', 'label': 'Peso Uruguay en Unidades Indexadas (URUIURUI)'},
|
||||
{'code': 'UYU', 'label': 'Peso Uruguayo'},
|
||||
{'code': 'UZS', 'label': 'Uzbekistán Sum'},
|
||||
{'code': 'VEF', 'label': 'Bolívar'},
|
||||
{'code': 'VND', 'label': 'Dong'},
|
||||
{'code': 'VUV', 'label': 'Vatu'},
|
||||
{'code': 'WST', 'label': 'Tala'},
|
||||
{'code': 'XAF', 'label': 'Franco CFA BEAC'},
|
||||
{'code': 'XAG', 'label': 'Plata'},
|
||||
{'code': 'XAU', 'label': 'Oro'},
|
||||
{'code': 'XBA', 'label': 'Unidad de Mercados de Bonos Unidad Europea Composite (EURCO)'},
|
||||
{'code': 'XBB', 'label': 'Unidad Monetaria de Bonos de Mercados Unidad Europea (UEM-6)'},
|
||||
{'code': 'XBC', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 9 (UCE-9)'},
|
||||
{'code': 'XBD', 'label': 'Mercados de Bonos Unidad Europea unidad de cuenta a 17 (UCE-17)'},
|
||||
{'code': 'XCD', 'label': 'Dólar del Caribe Oriental'},
|
||||
{'code': 'XDR', 'label': 'DEG (Derechos Especiales de Giro)'},
|
||||
{'code': 'XOF', 'label': 'Franco CFA BCEAO'},
|
||||
{'code': 'XPD', 'label': 'Paladio'},
|
||||
{'code': 'XPF', 'label': 'Franco CFP'},
|
||||
{'code': 'XPT', 'label': 'Platino'},
|
||||
{'code': 'XSU', 'label': 'Sucre'},
|
||||
{'code': 'XTS', 'label': 'Códigos reservados específicamente para propósitos de prueba'},
|
||||
{'code': 'XUA', 'label': 'Unidad ADB de Cuenta'},
|
||||
{'code': 'XXX',
|
||||
'label': 'Los códigos asignados para las transacciones en que intervenga ninguna moneda'},
|
||||
{'code': 'YER', 'label': 'Rial yemení'},
|
||||
{'code': 'ZAR', 'label': 'Rand'},
|
||||
{'code': 'ZMW', 'label': 'Kwacha zambiano'},
|
||||
{'code': 'ZWL', 'label': 'Zimbabwe Dólar'},
|
||||
{'code': 'NULL', 'label': 'NULL'}]},
|
||||
'pais': {'label': 'País (ISO 3166)',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'ABW', 'label': 'Aruba'},
|
||||
{'code': 'AFG', 'label': 'Afganistán'},
|
||||
{'code': 'AGO', 'label': 'Angola'},
|
||||
{'code': 'AIA', 'label': 'Anguila'},
|
||||
{'code': 'ALA', 'label': 'Islas Åland'},
|
||||
{'code': 'ALB', 'label': 'Albania'},
|
||||
{'code': 'AND', 'label': 'Andorra'},
|
||||
{'code': 'ARE', 'label': 'Emiratos Árabes Unidos (Los)'},
|
||||
{'code': 'ARG', 'label': 'Argentina'},
|
||||
{'code': 'ARM', 'label': 'Armenia'},
|
||||
{'code': 'ASM', 'label': 'Samoa Americana'},
|
||||
{'code': 'ATA', 'label': 'Antártida'},
|
||||
{'code': 'ATF', 'label': 'Territorios Australes Franceses (los)'},
|
||||
{'code': 'ATG', 'label': 'Antigua y Barbuda'},
|
||||
{'code': 'AUS', 'label': 'Australia'},
|
||||
{'code': 'AUT', 'label': 'Austria'},
|
||||
{'code': 'AZE', 'label': 'Azerbaiyán'},
|
||||
{'code': 'BDI', 'label': 'Burundi'},
|
||||
{'code': 'BEL', 'label': 'Bélgica'},
|
||||
{'code': 'BEN', 'label': 'Benín'},
|
||||
{'code': 'BES', 'label': 'Bonaire, San Eustaquio y Saba'},
|
||||
{'code': 'BFA', 'label': 'Burkina Faso'},
|
||||
{'code': 'BGD', 'label': 'Bangladés'},
|
||||
{'code': 'BGR', 'label': 'Bulgaria'},
|
||||
{'code': 'BHR', 'label': 'Baréin'},
|
||||
{'code': 'BHS', 'label': 'Bahamas (las)'},
|
||||
{'code': 'BIH', 'label': 'Bosnia y Herzegovina'},
|
||||
{'code': 'BLM', 'label': 'San Bartolomé'},
|
||||
{'code': 'BLR', 'label': 'Bielorrusia'},
|
||||
{'code': 'BLZ', 'label': 'Belice'},
|
||||
{'code': 'BMU', 'label': 'Bermudas'},
|
||||
{'code': 'BOL', 'label': 'Bolivia, Estado Plurinacional de'},
|
||||
{'code': 'BRA', 'label': 'Brasil'},
|
||||
{'code': 'BRB', 'label': 'Barbados'},
|
||||
{'code': 'BRN', 'label': 'Brunéi Darussalam'},
|
||||
{'code': 'BTN', 'label': 'Bután'},
|
||||
{'code': 'BVT', 'label': 'Isla Bouvet'},
|
||||
{'code': 'BWA', 'label': 'Botsuana'},
|
||||
{'code': 'CAF', 'label': 'República Centroafricana (la)'},
|
||||
{'code': 'CAN', 'label': 'Canadá'},
|
||||
{'code': 'CCK', 'label': 'Islas Cocos (Keeling)'},
|
||||
{'code': 'CHE', 'label': 'Suiza'},
|
||||
{'code': 'CHL', 'label': 'Chile'},
|
||||
{'code': 'CHN', 'label': 'China'},
|
||||
{'code': 'CIV', 'label': "Côte d'Ivoire"},
|
||||
{'code': 'CMR', 'label': 'Camerún'},
|
||||
{'code': 'COD', 'label': 'Congo (la República Democrática del)'},
|
||||
{'code': 'COG', 'label': 'Congo'},
|
||||
{'code': 'COK', 'label': 'Islas Cook (las)'},
|
||||
{'code': 'COL', 'label': 'Colombia'},
|
||||
{'code': 'COM', 'label': 'Comoras'},
|
||||
{'code': 'CPV', 'label': 'Cabo Verde'},
|
||||
{'code': 'CRI', 'label': 'Costa Rica'},
|
||||
{'code': 'CUB', 'label': 'Cuba'},
|
||||
{'code': 'CUW', 'label': 'Curaçao'},
|
||||
{'code': 'CXR', 'label': 'Isla de Navidad'},
|
||||
{'code': 'CYM', 'label': 'Islas Caimán (las)'},
|
||||
{'code': 'CYP', 'label': 'Chipre'},
|
||||
{'code': 'CZE', 'label': 'República Checa (la)'},
|
||||
{'code': 'DEU', 'label': 'Alemania'},
|
||||
{'code': 'DJI', 'label': 'Yibuti'},
|
||||
{'code': 'DMA', 'label': 'Dominica'},
|
||||
{'code': 'DNK', 'label': 'Dinamarca'},
|
||||
{'code': 'DOM', 'label': 'República Dominicana (la)'},
|
||||
{'code': 'DZA', 'label': 'Argelia'},
|
||||
{'code': 'ECU', 'label': 'Ecuador'},
|
||||
{'code': 'EGY', 'label': 'Egipto'},
|
||||
{'code': 'ERI', 'label': 'Eritrea'},
|
||||
{'code': 'ESH', 'label': 'Sahara Occidental'},
|
||||
{'code': 'ESP', 'label': 'España'},
|
||||
{'code': 'EST', 'label': 'Estonia'},
|
||||
{'code': 'ETH', 'label': 'Etiopía'},
|
||||
{'code': 'FIN', 'label': 'Finlandia'},
|
||||
{'code': 'FJI', 'label': 'Fiyi'},
|
||||
{'code': 'FLK', 'label': 'Islas Malvinas [Falkland] (las)'},
|
||||
{'code': 'FRA', 'label': 'Francia'},
|
||||
{'code': 'FRO', 'label': 'Islas Feroe (las)'},
|
||||
{'code': 'FSM', 'label': 'Micronesia (los Estados Federados de)'},
|
||||
{'code': 'GAB', 'label': 'Gabón'},
|
||||
{'code': 'GBR', 'label': 'Reino Unido (el)'},
|
||||
{'code': 'GEO', 'label': 'Georgia'},
|
||||
{'code': 'GGY', 'label': 'Guernsey'},
|
||||
{'code': 'GHA', 'label': 'Ghana'},
|
||||
{'code': 'GIB', 'label': 'Gibraltar'},
|
||||
{'code': 'GIN', 'label': 'Guinea'},
|
||||
{'code': 'GLP', 'label': 'Guadalupe'},
|
||||
{'code': 'GMB', 'label': 'Gambia (La)'},
|
||||
{'code': 'GNB', 'label': 'Guinea-Bisáu'},
|
||||
{'code': 'GNQ', 'label': 'Guinea Ecuatorial'},
|
||||
{'code': 'GRC', 'label': 'Grecia'},
|
||||
{'code': 'GRD', 'label': 'Granada'},
|
||||
{'code': 'GRL', 'label': 'Groenlandia'},
|
||||
{'code': 'GTM', 'label': 'Guatemala'},
|
||||
{'code': 'GUF', 'label': 'Guayana Francesa'},
|
||||
{'code': 'GUM', 'label': 'Guam'},
|
||||
{'code': 'GUY', 'label': 'Guyana'},
|
||||
{'code': 'HKG', 'label': 'Hong Kong'},
|
||||
{'code': 'HMD', 'label': 'Isla Heard e Islas McDonald'},
|
||||
{'code': 'HND', 'label': 'Honduras'},
|
||||
{'code': 'HRV', 'label': 'Croacia'},
|
||||
{'code': 'HTI', 'label': 'Haití'},
|
||||
{'code': 'HUN', 'label': 'Hungría'},
|
||||
{'code': 'IDN', 'label': 'Indonesia'},
|
||||
{'code': 'IMN', 'label': 'Isla de Man'},
|
||||
{'code': 'IND', 'label': 'India'},
|
||||
{'code': 'IOT', 'label': 'Territorio Británico del Océano Índico (el)'},
|
||||
{'code': 'IRL', 'label': 'Irlanda'},
|
||||
{'code': 'IRN', 'label': 'Irán (la República Islámica de)'},
|
||||
{'code': 'IRQ', 'label': 'Irak'},
|
||||
{'code': 'ISL', 'label': 'Islandia'},
|
||||
{'code': 'ISR', 'label': 'Israel'},
|
||||
{'code': 'ITA', 'label': 'Italia'},
|
||||
{'code': 'JAM', 'label': 'Jamaica'},
|
||||
{'code': 'JEY', 'label': 'Jersey'},
|
||||
{'code': 'JOR', 'label': 'Jordania'},
|
||||
{'code': 'JPN', 'label': 'Japón'},
|
||||
{'code': 'KAZ', 'label': 'Kazajistán'},
|
||||
{'code': 'KEN', 'label': 'Kenia'},
|
||||
{'code': 'KGZ', 'label': 'Kirguistán'},
|
||||
{'code': 'KHM', 'label': 'Camboya'},
|
||||
{'code': 'KIR', 'label': 'Kiribati'},
|
||||
{'code': 'KNA', 'label': 'San Cristóbal y Nieves'},
|
||||
{'code': 'KOR', 'label': 'Corea (la República de)'},
|
||||
{'code': 'KWT', 'label': 'Kuwait'},
|
||||
{'code': 'LAO', 'label': 'Lao, (la) República Democrática Popular'},
|
||||
{'code': 'LBN', 'label': 'Líbano'},
|
||||
{'code': 'LBR', 'label': 'Liberia'},
|
||||
{'code': 'LBY', 'label': 'Libia'},
|
||||
{'code': 'LCA', 'label': 'Santa Lucía'},
|
||||
{'code': 'LIE', 'label': 'Liechtenstein'},
|
||||
{'code': 'LKA', 'label': 'Sri Lanka'},
|
||||
{'code': 'LSO', 'label': 'Lesoto'},
|
||||
{'code': 'LTU', 'label': 'Lituania'},
|
||||
{'code': 'LUX', 'label': 'Luxemburgo'},
|
||||
{'code': 'LVA', 'label': 'Letonia'},
|
||||
{'code': 'MAC', 'label': 'Macao'},
|
||||
{'code': 'MAF', 'label': 'San Martín (parte francesa)'},
|
||||
{'code': 'MAR', 'label': 'Marruecos'},
|
||||
{'code': 'MCO', 'label': 'Mónaco'},
|
||||
{'code': 'MDA', 'label': 'Moldavia (la República de)'},
|
||||
{'code': 'MDG', 'label': 'Madagascar'},
|
||||
{'code': 'MDV', 'label': 'Maldivas'},
|
||||
{'code': 'MEX', 'label': 'México'},
|
||||
{'code': 'MHL', 'label': 'Islas Marshall (las)'},
|
||||
{'code': 'MKD', 'label': 'Macedonia (la antigua República Yugoslava de)'},
|
||||
{'code': 'MLI', 'label': 'Malí'},
|
||||
{'code': 'MLT', 'label': 'Malta'},
|
||||
{'code': 'MMR', 'label': 'Myanmar'},
|
||||
{'code': 'MNE', 'label': 'Montenegro'},
|
||||
{'code': 'MNG', 'label': 'Mongolia'},
|
||||
{'code': 'MNP', 'label': 'Islas Marianas del Norte (las)'},
|
||||
{'code': 'MOZ', 'label': 'Mozambique'},
|
||||
{'code': 'MRT', 'label': 'Mauritania'},
|
||||
{'code': 'MSR', 'label': 'Montserrat'},
|
||||
{'code': 'MTQ', 'label': 'Martinica'},
|
||||
{'code': 'MUS', 'label': 'Mauricio'},
|
||||
{'code': 'MWI', 'label': 'Malaui'},
|
||||
{'code': 'MYS', 'label': 'Malasia'},
|
||||
{'code': 'MYT', 'label': 'Mayotte'},
|
||||
{'code': 'NAM', 'label': 'Namibia'},
|
||||
{'code': 'NCL', 'label': 'Nueva Caledonia'},
|
||||
{'code': 'NER', 'label': 'Níger (el)'},
|
||||
{'code': 'NFK', 'label': 'Isla Norfolk'},
|
||||
{'code': 'NGA', 'label': 'Nigeria'},
|
||||
{'code': 'NIC', 'label': 'Nicaragua'},
|
||||
{'code': 'NIU', 'label': 'Niue'},
|
||||
{'code': 'NLD', 'label': 'Países Bajos (los)'},
|
||||
{'code': 'NOR', 'label': 'Noruega'},
|
||||
{'code': 'NPL', 'label': 'Nepal'},
|
||||
{'code': 'NRU', 'label': 'Nauru'},
|
||||
{'code': 'NZL', 'label': 'Nueva Zelanda'},
|
||||
{'code': 'OMN', 'label': 'Omán'},
|
||||
{'code': 'PAK', 'label': 'Pakistán'},
|
||||
{'code': 'PAN', 'label': 'Panamá'},
|
||||
{'code': 'PCN', 'label': 'Pitcairn'},
|
||||
{'code': 'PER', 'label': 'Perú'},
|
||||
{'code': 'PHL', 'label': 'Filipinas (las)'},
|
||||
{'code': 'PLW', 'label': 'Palaos'},
|
||||
{'code': 'PNG', 'label': 'Papúa Nueva Guinea'},
|
||||
{'code': 'POL', 'label': 'Polonia'},
|
||||
{'code': 'PRI', 'label': 'Puerto Rico'},
|
||||
{'code': 'PRK', 'label': 'Corea (la República Democrática Popular de)'},
|
||||
{'code': 'PRT', 'label': 'Portugal'},
|
||||
{'code': 'PRY', 'label': 'Paraguay'},
|
||||
{'code': 'PSE', 'label': 'Palestina, Estado de'},
|
||||
{'code': 'PYF', 'label': 'Polinesia Francesa'},
|
||||
{'code': 'QAT', 'label': 'Catar'},
|
||||
{'code': 'REU', 'label': 'Reunión'},
|
||||
{'code': 'ROU', 'label': 'Rumania'},
|
||||
{'code': 'RUS', 'label': 'Rusia, (la) Federación de'},
|
||||
{'code': 'RWA', 'label': 'Ruanda'},
|
||||
{'code': 'SAU', 'label': 'Arabia Saudita'},
|
||||
{'code': 'SDN', 'label': 'Sudán (el)'},
|
||||
{'code': 'SEN', 'label': 'Senegal'},
|
||||
{'code': 'SGP', 'label': 'Singapur'},
|
||||
{'code': 'SGS', 'label': 'Georgia del sur y las islas sandwich del sur'},
|
||||
{'code': 'SHN', 'label': 'Santa Helena, Ascensión y Tristán de Acuña'},
|
||||
{'code': 'SJM', 'label': 'Svalbard y Jan Mayen'},
|
||||
{'code': 'SLB', 'label': 'Islas Salomón (las)'},
|
||||
{'code': 'SLE', 'label': 'Sierra leona'},
|
||||
{'code': 'NULL', 'label': 'NULL'}]},
|
||||
'estado': {'label': 'Estado / Provincia',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'AGU', 'label': 'Aguascalientes', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'BCN', 'label': 'Baja California', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'BCS', 'label': 'Baja California Sur', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CAM', 'label': 'Campeche', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CHP', 'label': 'Chiapas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CHH', 'label': 'Chihuahua', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'CMX', 'label': 'Ciudad de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'COA', 'label': 'Coahuila', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'COL', 'label': 'Colima', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'DUR', 'label': 'Durango', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'GUA', 'label': 'Guanajuato', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'GRO', 'label': 'Guerrero', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'HID', 'label': 'Hidalgo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'JAL', 'label': 'Jalisco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'MEX', 'label': 'Estado de México', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'MIC', 'label': 'Michoacán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'MOR', 'label': 'Morelos', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'NAY', 'label': 'Nayarit', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'NLE', 'label': 'Nuevo León', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'OAX', 'label': 'Oaxaca', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'PUE', 'label': 'Puebla', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'QUE', 'label': 'Querétaro', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'ROO', 'label': 'Quintana Roo', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'SLP', 'label': 'San Luis Potosí', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'SIN', 'label': 'Sinaloa', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'SON', 'label': 'Sonora', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'TAB', 'label': 'Tabasco', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'TAM', 'label': 'Tamaulipas', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'TLA', 'label': 'Tlaxcala', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'VER', 'label': 'Veracruz', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'YUC', 'label': 'Yucatán', 'parent_catalog': 'pais', 'parent_code': 'MEX'},
|
||||
{'code': 'ZAC', 'label': 'Zacatecas', 'parent_catalog': 'pais', 'parent_code': 'MEX'}]},
|
||||
'tipo_domicilio': {'label': 'Tipo de domicilio',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'fiscal', 'label': 'Fiscal'},
|
||||
{'code': 'oficina', 'label': 'Oficina'},
|
||||
{'code': 'sucursal', 'label': 'Sucursal'},
|
||||
{'code': 'bodega', 'label': 'Bodega'},
|
||||
{'code': 'patio', 'label': 'Patio'},
|
||||
{'code': 'terminal', 'label': 'Terminal'},
|
||||
{'code': 'almacen', 'label': 'Almacén'}]},
|
||||
'area': {'label': 'Área / Departamento',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'ventas', 'label': 'Ventas'},
|
||||
{'code': 'operaciones', 'label': 'Operaciones'},
|
||||
{'code': 'facturacion', 'label': 'Facturación'},
|
||||
{'code': 'cobranza', 'label': 'Cobranza'},
|
||||
{'code': 'servicio_cliente', 'label': 'Servicio al cliente'}]},
|
||||
'cobertura': {'label': 'Cobertura',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'nacional', 'label': 'Nacional'},
|
||||
{'code': 'internacional', 'label': 'Internacional'}]},
|
||||
'clasificacion_proveedor': {'label': 'Clasificación del proveedor',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'naviera', 'label': 'Naviera'},
|
||||
{'code': 'aerolinea', 'label': 'Aerolínea'},
|
||||
{'code': 'transportista_terrestre', 'label': 'Transportista Terrestre'},
|
||||
{'code': 'ferrocarril', 'label': 'Ferrocarril'},
|
||||
{'code': 'agente_aduanal', 'label': 'Agente Aduanal'},
|
||||
{'code': 'agente_carga', 'label': 'Agente de Carga'},
|
||||
{'code': 'agente_corresponsal', 'label': 'Agente Corresponsal'},
|
||||
{'code': 'almacen', 'label': 'Almacén'},
|
||||
{'code': 'aseguradora', 'label': 'Aseguradora'},
|
||||
{'code': 'paqueteria', 'label': 'Paquetería'},
|
||||
{'code': 'otro', 'label': 'Otro'}]},
|
||||
'tipo_equipo': {'label': 'Tipo de equipo / contenedor',
|
||||
'is_system': True,
|
||||
'items': [{'code': '40DC',
|
||||
'label': "40' Standard",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 12.035,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.392,
|
||||
'capacidad_m3': 67.7,
|
||||
'tara_kg': 3700,
|
||||
'carga_max_kg': 26790}},
|
||||
{'code': '20DC',
|
||||
'label': "20' Standard",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.9,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.392,
|
||||
'capacidad_m3': 33.2,
|
||||
'tara_kg': 2230,
|
||||
'carga_max_kg': 21770}},
|
||||
{'code': '20OT',
|
||||
'label': "20' Open Top",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.894,
|
||||
'ancho_m': 2.311,
|
||||
'alto_m': 2.354,
|
||||
'capacidad_m3': 32.23,
|
||||
'tara_kg': 2400,
|
||||
'carga_max_kg': 30490}},
|
||||
{'code': '20FR',
|
||||
'label': "20' Flat Rack",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.62,
|
||||
'ancho_m': 2.23,
|
||||
'alto_m': 2.233,
|
||||
'tara_kg': 2530,
|
||||
'carga_max_kg': 21470}},
|
||||
{'code': '40HC',
|
||||
'label': "40' High Cube",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 12.036,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.697,
|
||||
'capacidad_m3': 76.3,
|
||||
'tara_kg': 3970,
|
||||
'carga_max_kg': 26510}},
|
||||
{'code': '20PL',
|
||||
'label': "20' Platform",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 6.058,
|
||||
'ancho_m': 2.438,
|
||||
'alto_m': 0.37,
|
||||
'tara_kg': 2520,
|
||||
'carga_max_kg': 27960}},
|
||||
{'code': '20FRC',
|
||||
'label': "20' Flat Rack Collapsible",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.618,
|
||||
'ancho_m': 2.206,
|
||||
'alto_m': 2.233,
|
||||
'tara_kg': 2750,
|
||||
'carga_max_kg': 27730}},
|
||||
{'code': '20BK',
|
||||
'label': "20' Bulk",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 5.93,
|
||||
'ancho_m': 2.35,
|
||||
'alto_m': 2.34,
|
||||
'capacidad_m3': 32.0,
|
||||
'tara_kg': 2450,
|
||||
'carga_max_kg': 21350}},
|
||||
{'code': '20TK',
|
||||
'label': "20' Tank",
|
||||
'extra': {'modo': 'maritimo',
|
||||
'largo_m': 6.058,
|
||||
'ancho_m': 2.438,
|
||||
'alto_m': 2.438,
|
||||
'tara_kg': 4100,
|
||||
'carga_max_kg': 26200}},
|
||||
{'code': 'LD2',
|
||||
'label': 'LD2',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 3.5,
|
||||
'tara_kg': 30,
|
||||
'carga_max_kg': 1225,
|
||||
'nota': 'Aviones 767'}},
|
||||
{'code': 'LD3',
|
||||
'label': 'LD3',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 4.2,
|
||||
'tara_kg': 80,
|
||||
'carga_max_kg': 1587,
|
||||
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
|
||||
{'code': 'LBD',
|
||||
'label': 'LBD (Flex Door)',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 7.0,
|
||||
'tara_kg': 123,
|
||||
'carga_max_kg': 2449,
|
||||
'nota': 'Aviones 767'}},
|
||||
{'code': 'LD6',
|
||||
'label': 'LD6',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 8.9,
|
||||
'tara_kg': 175,
|
||||
'carga_max_kg': 3175,
|
||||
'nota': 'B747/B777/DC10/MD-11/A310/A330/A340'}},
|
||||
{'code': 'PAG',
|
||||
'label': 'PAP / PIP / PAG',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 10.0,
|
||||
'tara_kg': 120,
|
||||
'carga_max_kg': 6033,
|
||||
'nota': 'Boeing 747/767/777/DC10'}},
|
||||
{'code': 'LD9',
|
||||
'label': 'LD9 AAP',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 10.0,
|
||||
'tara_kg': 85,
|
||||
'carga_max_kg': 1588,
|
||||
'nota': 'Boeing 747/777/DC10'}},
|
||||
{'code': 'XAW',
|
||||
'label': 'XAW',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 14.0,
|
||||
'tara_kg': 170,
|
||||
'carga_max_kg': 5000,
|
||||
'nota': 'Boeing 747/777/DC10'}},
|
||||
{'code': 'PMC',
|
||||
'label': 'PMC',
|
||||
'extra': {'modo': 'aereo',
|
||||
'capacidad_m3': 12.7,
|
||||
'tara_kg': 130,
|
||||
'carga_max_kg': 6804,
|
||||
'nota': 'Boeing 747/767/777'}},
|
||||
{'code': 'LD8',
|
||||
'label': 'LD8',
|
||||
'extra': {'modo': 'aereo', 'capacidad_m3': 7.2, 'tara_kg': 120, 'carga_max_kg': 2450}},
|
||||
{'code': 'DV48',
|
||||
'label': "Dry Van 48'",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 14.63,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 98.0,
|
||||
'carga_max_kg': 20412,
|
||||
'pallets': 22}},
|
||||
{'code': 'SD',
|
||||
'label': 'Legal Step Deck (Single Drop)',
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 11.58,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 3.05,
|
||||
'carga_max_kg': 20865}},
|
||||
{'code': 'TANK',
|
||||
'label': 'Tanker',
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 16.15,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_l': 22712}},
|
||||
{'code': 'DV53',
|
||||
'label': "Dry Van 53'",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 16.15,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 99.11,
|
||||
'carga_max_kg': 20412,
|
||||
'pallets': 26}},
|
||||
{'code': 'DD',
|
||||
'label': 'Double Drop (Low Boy)',
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 8.53,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 3.51,
|
||||
'carga_max_kg': 18144}},
|
||||
{'code': 'RF48',
|
||||
'label': "48' Reefer Trailer",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 14.63,
|
||||
'ancho_m': 2.4,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 90.0,
|
||||
'carga_max_kg': 19958,
|
||||
'pallets': 20}},
|
||||
{'code': 'FB48',
|
||||
'label': "48' Legal Flatbed",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 14.63,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.59,
|
||||
'carga_max_kg': 21772}},
|
||||
{'code': 'PUP28',
|
||||
'label': "Pup Trailer 28'",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 8.53,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 57.45,
|
||||
'carga_max_kg': 9979,
|
||||
'pallets': 14}},
|
||||
{'code': 'IM53',
|
||||
'label': "Intermodal 53' Container",
|
||||
'extra': {'modo': 'terrestre',
|
||||
'largo_m': 16.15,
|
||||
'ancho_m': 2.59,
|
||||
'alto_m': 2.3,
|
||||
'capacidad_m3': 99.11,
|
||||
'carga_max_kg': 19958,
|
||||
'pallets': 24}}]},
|
||||
'modo_tarifario': {'label': 'Modo de tarifario',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'aereo', 'label': 'Aéreo'},
|
||||
{'code': 'maritimo_fcl', 'label': 'Marítimo FCL'},
|
||||
{'code': 'maritimo_lcl', 'label': 'Marítimo LCL'},
|
||||
{'code': 'terrestre', 'label': 'Terrestre'}]},
|
||||
'unidad_tarifa': {'label': 'Unidad de tarifa',
|
||||
'is_system': True,
|
||||
'items': [{'code': 'per_kg', 'label': 'Por kg'},
|
||||
{'code': 'per_wm', 'label': 'Por peso/medida (W/M)'},
|
||||
{'code': 'per_container', 'label': 'Por contenedor'},
|
||||
{'code': 'flat', 'label': 'Tarifa plana'}]},
|
||||
'concepto_cargo': {'label': 'Concepto de cargo',
|
||||
'is_system': False,
|
||||
'items': [{'code': 'combustible', 'label': 'Combustible (BAF/FSC)'},
|
||||
{'code': 'dgr', 'label': 'Mercancía peligrosa (DGR)'},
|
||||
{'code': 'moc', 'label': 'MOC (mínimo origen)'},
|
||||
{'code': 'afs', 'label': 'AFS'},
|
||||
{'code': 'thc', 'label': 'THC (manejo en terminal)'},
|
||||
{'code': 'maniobras', 'label': 'Maniobras'},
|
||||
{'code': 'almacenaje', 'label': 'Almacenaje'},
|
||||
{'code': 'seguro', 'label': 'Seguro'},
|
||||
{'code': 'despacho', 'label': 'Despacho aduanal'},
|
||||
{'code': 'documentacion', 'label': 'Documentación'},
|
||||
{'code': 'custodia', 'label': 'Custodia'},
|
||||
{'code': 'otro', 'label': 'Otro'}]}}
|
||||
|
||||
TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece',
|
||||
'puerto': 'Puertos donde opera',
|
||||
'aeropuerto': 'Aeropuertos donde opera',
|
||||
'aduana': 'Aduanas donde opera'}
|
||||
168
backend/api/v1/modules/crm/catalogs/service.py
Normal file
168
backend/api/v1/modules/crm/catalogs/service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Lógica de negocio de los catálogos de referencia del CRM."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.security import is_hub_admin
|
||||
|
||||
from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta
|
||||
from .models import CatalogItem
|
||||
from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS
|
||||
|
||||
# Metadata de catálogos (labels y si el cliente puede llenarlos).
|
||||
CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()}
|
||||
CATALOG_LABELS.update(TENANT_CATALOG_LABELS)
|
||||
# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft).
|
||||
TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys())
|
||||
KNOWN_CATALOGS = set(CATALOG_LABELS.keys())
|
||||
|
||||
|
||||
def _require_known(catalog: str) -> None:
|
||||
if catalog not in KNOWN_CATALOGS:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe")
|
||||
|
||||
|
||||
def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]:
|
||||
"""Lista todos los catálogos disponibles con su conteo (global + del tenant)."""
|
||||
out: list[CatalogMeta] = []
|
||||
for key, label in CATALOG_LABELS.items():
|
||||
is_tenant = key in TENANT_CATALOG_KEYS
|
||||
count = (
|
||||
db.query(CatalogItem)
|
||||
.filter(
|
||||
CatalogItem.catalog == key,
|
||||
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
out.append(
|
||||
CatalogMeta(
|
||||
catalog=key,
|
||||
label=label,
|
||||
scope="tenant" if is_tenant else "global",
|
||||
is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)),
|
||||
count=count,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def list_items(
|
||||
db: Session,
|
||||
catalog: str,
|
||||
tenant_id: int,
|
||||
parent_code: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> list[CatalogItem]:
|
||||
_require_known(catalog)
|
||||
q = db.query(CatalogItem).filter(
|
||||
CatalogItem.catalog == catalog,
|
||||
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
|
||||
)
|
||||
if not include_inactive:
|
||||
q = q.filter(CatalogItem.is_active.is_(True))
|
||||
if parent_code:
|
||||
q = q.filter(CatalogItem.parent_code == parent_code)
|
||||
return q.order_by(CatalogItem.sort_order, CatalogItem.label).all()
|
||||
|
||||
|
||||
def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None:
|
||||
"""Devuelve el tenant_id a usar al escribir (None = global) y valida permisos.
|
||||
|
||||
- scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft).
|
||||
- scope 'tenant' (default) → se guarda en el tenant del usuario.
|
||||
"""
|
||||
wants_global = scope == "global"
|
||||
if wants_global:
|
||||
if not is_hub_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo un administrador de Aduanasoft puede editar catálogos globales.",
|
||||
)
|
||||
return None
|
||||
return int(current_user["tenant_id"])
|
||||
|
||||
|
||||
def create_item(
|
||||
db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None
|
||||
) -> CatalogItem:
|
||||
_require_known(catalog)
|
||||
target_tenant = _resolve_write_scope(catalog, scope, current_user)
|
||||
|
||||
# No duplicar por (catalog, code, tenant_id)
|
||||
exists = (
|
||||
db.query(CatalogItem)
|
||||
.filter(
|
||||
CatalogItem.catalog == catalog,
|
||||
CatalogItem.code == data.code,
|
||||
CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.",
|
||||
)
|
||||
|
||||
item = CatalogItem(
|
||||
catalog=catalog,
|
||||
code=data.code,
|
||||
label=data.label,
|
||||
parent_catalog=data.parent_catalog,
|
||||
parent_code=data.parent_code,
|
||||
tenant_id=target_tenant,
|
||||
sort_order=data.sort_order,
|
||||
is_active=data.is_active,
|
||||
is_system=False,
|
||||
created_by=current_user.get("sub"),
|
||||
updated_by=current_user.get("sub"),
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem:
|
||||
_require_known(catalog)
|
||||
item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
|
||||
if item.tenant_id is None:
|
||||
# Global (Aduanasoft): solo hub_admin.
|
||||
if not is_hub_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo un administrador de Aduanasoft puede editar este catálogo global.",
|
||||
)
|
||||
elif item.tenant_id != int(current_user["tenant_id"]):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
|
||||
return item
|
||||
|
||||
|
||||
def update_item(
|
||||
db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict
|
||||
) -> CatalogItem:
|
||||
item = _get_writable(db, catalog, item_id, current_user)
|
||||
payload: dict[str, Any] = data.model_dump(exclude_unset=True)
|
||||
for field, value in payload.items():
|
||||
setattr(item, field, value)
|
||||
item.updated_by = current_user.get("sub")
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None:
|
||||
item = _get_writable(db, catalog, item_id, current_user)
|
||||
if item.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.",
|
||||
)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
376
backend/api/v1/modules/crm/quotes/pdf.py
Normal file
376
backend/api/v1/modules/crm/quotes/pdf.py
Normal 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)
|
||||
241
backend/api/v1/modules/crm/quotes/pdf_service.py
Normal file
241
backend/api/v1/modules/crm/quotes/pdf_service.py
Normal 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}
|
||||
@@ -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,
|
||||
|
||||
0
backend/api/v1/modules/crm/rates/__init__.py
Normal file
0
backend/api/v1/modules/crm/rates/__init__.py
Normal file
174
backend/api/v1/modules/crm/rates/dto.py
Normal file
174
backend/api/v1/modules/crm/rates/dto.py
Normal 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]
|
||||
93
backend/api/v1/modules/crm/rates/models.py
Normal file
93
backend/api/v1/modules/crm/rates/models.py
Normal 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)
|
||||
257
backend/api/v1/modules/crm/rates/routes.py
Normal file
257
backend/api/v1/modules/crm/rates/routes.py
Normal 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)
|
||||
535
backend/api/v1/modules/crm/rates/service.py
Normal file
535
backend/api/v1/modules/crm/rates/service.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -13,6 +13,7 @@ class SupplierBase(BaseModel):
|
||||
person_type: str | None = Field(None, max_length=10)
|
||||
status: str = Field("active", max_length=20)
|
||||
classifications: list[str] = Field(default_factory=list)
|
||||
classification_other: str | None = Field(None, max_length=120)
|
||||
# Comercial
|
||||
services_offered: str | None = None
|
||||
coverage: str | None = Field(None, max_length=20)
|
||||
@@ -52,6 +53,7 @@ class SupplierUpdate(BaseModel):
|
||||
person_type: str | None = Field(None, max_length=10)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
classifications: list[str] | None = None
|
||||
classification_other: str | None = Field(None, max_length=120)
|
||||
services_offered: str | None = None
|
||||
coverage: str | None = Field(None, max_length=20)
|
||||
countries: list[str] | None = None
|
||||
|
||||
@@ -30,6 +30,8 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril,
|
||||
# agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro
|
||||
classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
|
||||
# Texto libre cuando la clasificación incluye "otro"
|
||||
classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
|
||||
# ----- Información comercial -----
|
||||
services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
67
backend/core/hub_token.py
Normal file
67
backend/core/hub_token.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub.
|
||||
|
||||
Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256)
|
||||
que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en
|
||||
la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from jose import jwt
|
||||
|
||||
from core.config import settings
|
||||
from core import session_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool:
|
||||
"""True si el token KC no está expirado (con margen)."""
|
||||
try:
|
||||
claims = jwt.get_unverified_claims(token)
|
||||
exp = claims.get("exp")
|
||||
return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_hub_access_token(request) -> Optional[str]:
|
||||
"""
|
||||
Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid),
|
||||
refrescándolo contra el Hub si está por expirar. None si no hay sesión.
|
||||
Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido).
|
||||
"""
|
||||
sid = request.cookies.get("crm_sid") if request is not None else None
|
||||
if not sid:
|
||||
return None
|
||||
sess = session_store.get_session(sid)
|
||||
if not sess:
|
||||
return None
|
||||
|
||||
access = sess.get("access_token")
|
||||
refresh = sess.get("refresh_token")
|
||||
|
||||
if access and _kc_exp_ok(access):
|
||||
return access
|
||||
|
||||
if refresh:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
new_access = data.get("access_token") or access
|
||||
session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh)
|
||||
return new_access
|
||||
logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("get_hub_access_token: refresh falló: %s", exc)
|
||||
|
||||
return access
|
||||
@@ -3,6 +3,7 @@ import time
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -12,6 +13,11 @@ from .security import get_tenant_from_token, verify_token, get_active_system
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al
|
||||
# Hub en cada request. Valor: "valid" o "invalid:<mensaje>". TTL corto para que
|
||||
# los cambios de licencia se propaguen en minutos.
|
||||
_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600)
|
||||
|
||||
|
||||
def _normalize_text(value: str | None) -> str:
|
||||
if not value:
|
||||
@@ -145,6 +151,18 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
# Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que
|
||||
# el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC
|
||||
# guardado en valkey y se cachea por tenant.
|
||||
if getattr(settings, "SESSION_STORE_ENABLED", False):
|
||||
try:
|
||||
from core.local_session import verify_session_token
|
||||
local_claims = verify_session_token(token)
|
||||
except Exception:
|
||||
local_claims = None
|
||||
if local_claims is not None:
|
||||
return await self._handle_local_session_license(request, call_next, local_claims)
|
||||
|
||||
tenant_override = request.headers.get("X-Tenant-Override")
|
||||
if not tenant_override:
|
||||
# Fallback para flujos SSO cuando el override no viaja en header.
|
||||
@@ -307,6 +325,136 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict):
|
||||
"""
|
||||
Valida licencia para una sesión local del CRM (patrón SIWEB).
|
||||
|
||||
El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en
|
||||
valkey (refrescándolo si está vencido) para consultar verify-license, con
|
||||
caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se
|
||||
permite el paso: la sesión local se emitió tras un login válido (el App
|
||||
Launcher solo ofrece apps licenciadas), evitando bloquear por un problema
|
||||
transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean.
|
||||
"""
|
||||
from core import session_store
|
||||
|
||||
tenant_key = str(local_claims.get("tenant_id") or "")
|
||||
|
||||
cached = _license_cache.get(tenant_key) if tenant_key else None
|
||||
if cached == "valid":
|
||||
return await call_next(request)
|
||||
if isinstance(cached, str) and cached.startswith("invalid:"):
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402},
|
||||
)
|
||||
|
||||
tenant_override = (
|
||||
tenant_key
|
||||
or request.cookies.get("sso_tenant_id")
|
||||
or request.cookies.get("sso_tenant_pub")
|
||||
or ""
|
||||
)
|
||||
|
||||
sid = request.cookies.get("crm_sid")
|
||||
sess = session_store.get_session(sid) if sid else None
|
||||
kc_token = (sess or {}).get("access_token") or ""
|
||||
kc_refresh = (sess or {}).get("refresh_token") or ""
|
||||
|
||||
async def _verify(tok: str):
|
||||
if not tok:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {tok}"}
|
||||
if tenant_override:
|
||||
headers["X-Tenant-Override"] = str(tenant_override)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
return await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[license] verify-license (sesión local) error de red: %s", exc)
|
||||
return None
|
||||
|
||||
resp = await _verify(kc_token)
|
||||
|
||||
# ¿El KC token guardado está vencido? Refrescar una vez y reintentar.
|
||||
needs_refresh = resp is None or resp.status_code == 401
|
||||
if not needs_refresh and resp.status_code == 200:
|
||||
try:
|
||||
_d = resp.json()
|
||||
except Exception:
|
||||
_d = {}
|
||||
if not _d.get("valid", False) and _is_token_issue_message(
|
||||
_d.get("message"), _d.get("detail"), _d.get("reason")
|
||||
):
|
||||
needs_refresh = True
|
||||
|
||||
if needs_refresh and kc_refresh:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
rr = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json={"refresh_token": kc_refresh},
|
||||
)
|
||||
if rr.status_code == 200:
|
||||
nt = rr.json()
|
||||
kc_token = nt.get("access_token") or kc_token
|
||||
if sid:
|
||||
session_store.update_session_tokens(
|
||||
sid, kc_token, nt.get("refresh_token") or kc_refresh
|
||||
)
|
||||
resp = await _verify(kc_token)
|
||||
else:
|
||||
logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("[license] refresh KC para verify-license falló: %s", exc)
|
||||
|
||||
if resp is not None and resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("valid", False):
|
||||
expires_at_str = data.get("expires_at")
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción."
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = f"invalid:{msg}"
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402},
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = "valid"
|
||||
request.state.license_info = data
|
||||
return await call_next(request)
|
||||
|
||||
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||
if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")):
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = f"invalid:{message}"
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_ERROR", "message": message, "status_code": 402},
|
||||
)
|
||||
|
||||
# No concluyente (Hub no dio 200, o el problema de token persiste porque su
|
||||
# refresh falla): la sesión local es válida → permitir sin cachear. Evita el
|
||||
# bucle de 401 por el bug de refresh del Hub.
|
||||
logger.warning(
|
||||
"[license] verify-license no concluyente para sesión local (tenant=%s) — se permite",
|
||||
tenant_key,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
@@ -666,6 +666,20 @@ def validate_access_to_resource(
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
|
||||
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
|
||||
# por compañía seleccionada cuando el token no está ligado a un tenant.
|
||||
if tenant_id is None and company_id:
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
row = db.execute(
|
||||
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
tenant_id = int(row[0])
|
||||
except Exception as exc:
|
||||
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
|
||||
|
||||
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
|
||||
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
|
||||
# Se reemplazó el antiguo "admin" in realm_access.roles para que la
|
||||
|
||||
162
deploy/RUNBOOK-produccion.md
Normal file
162
deploy/RUNBOOK-produccion.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Runbook — Despliegue a PRODUCCIÓN · CRM Agente de Carga
|
||||
|
||||
> **Quién ejecuta:** un operador con acceso al servidor de producción y a la base de
|
||||
> datos de prod. **Este runbook no lo ejecuta ningún agente automático.**
|
||||
> **Prerrequisito de proceso:** el PR de `feature/crm-workspace-altas-org-usuario`
|
||||
> debe estar **revisado y mergeado** a la rama que corresponda antes de desplegar.
|
||||
>
|
||||
> **Antes de empezar: respaldo.** Toma un backup de la base `crm_core` de prod
|
||||
> (`pg_dump`) y del `.env` actual. Sin respaldo verificado, no continúes.
|
||||
|
||||
Este cambio es grande (auth/RBAC): login 100% vía Workspace/Hub, **sesión local
|
||||
(patrón SIWEB)**, gestión de compañías/usuarios/roles y provisión vía Hub. Léelo
|
||||
completo antes de tocar prod.
|
||||
|
||||
---
|
||||
|
||||
## 0. Alcance del cambio
|
||||
|
||||
- **Auth:** el login deja de hablar directo con Keycloak; todo pasa por el Hub
|
||||
(App Launcher → `/auth/sso?relay=<uuid>` → `POST {HUB_URL}/api/v1/auth/sso-exchange`).
|
||||
- **Sesión local:** cookie de sesión propia (HS256) + token KC guardado en **valkey**
|
||||
por `crm_sid`. Requiere valkey arriba. Se controla con `SESSION_STORE_ENABLED`.
|
||||
- **RBAC/Compañías:** compañías en `a76.company` por tenant; roles por carril
|
||||
(Ventas, Operaciones, Facturación, Consulta); permisos por módulo.
|
||||
|
||||
**Migraciones de esquema:** este set **no** agrega tablas nuevas (usa `core.*`,
|
||||
`a76.company` y valkey). Aun así, **verifica migraciones pendientes** en prod antes
|
||||
de desplegar (paso 5). No corras migraciones a ciegas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerrequisitos de infraestructura
|
||||
|
||||
- [ ] DNS de prod (p. ej. `crm.aduanasoft.com`) apuntando al server de prod.
|
||||
- [ ] Docker + Docker Compose en el server.
|
||||
- [ ] Servicios del stack: `backend`, `frontend`, `postgres`, `valkey`, `minio`,
|
||||
`celery_worker`, `celery_beat`.
|
||||
- [ ] **valkey** operativo (lo usan la sesión local y `hub_token`).
|
||||
- [ ] nginx + TLS (certbot) para servir app + API en el **mismo origen**.
|
||||
- [ ] Acceso al **Hub de producción** (no el de testing) y al client/realm correctos.
|
||||
|
||||
---
|
||||
|
||||
## 2. Variables de entorno (`.env` en el server de prod)
|
||||
|
||||
Basado en `deploy/env.testing.example`, pero con **hosts, dominio y secretos de
|
||||
producción**. Nunca subas el `.env` con secretos al repo.
|
||||
|
||||
```env
|
||||
# --- Seguridad ---
|
||||
ENVIRONMENT=production
|
||||
DEV_LOCAL_AUTH=false
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
|
||||
# --- Sesión local (patrón SIWEB) ---
|
||||
SESSION_STORE_ENABLED=true
|
||||
SESSION_IDLE_MINUTES=30
|
||||
SESSION_MAX_HOURS=10
|
||||
VALKEY_URL=redis://valkey:6379/0
|
||||
|
||||
# --- Workspace / Hub de PRODUCCIÓN (mismo Hub que genera el relay) ---
|
||||
WORKSPACE_URL=https://<hub-produccion>
|
||||
HUB_URL=https://<hub-produccion>
|
||||
INTERNAL_HUB_URL=https://<hub-produccion>
|
||||
VITE_HUB_URL=https://<hub-produccion>
|
||||
|
||||
# --- Keycloak (single-realm / single-client) ---
|
||||
KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
VITE_KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
KEYCLOAK_REALM=master
|
||||
KEYCLOAK_CLIENT_ID=aduanasoft
|
||||
KEYCLOAK_CLIENT_SECRET=<secret del producto provisionado en prod>
|
||||
|
||||
# --- Dominio del CRM (mismo origen app + API vía nginx) ---
|
||||
ORIGIN=https://<dominio-crm-produccion>
|
||||
APP_PUBLIC_URL=https://<dominio-crm-produccion>
|
||||
VITE_API_URL=https://<dominio-crm-produccion>/api/
|
||||
INTERNAL_API_URL=http://backend:8000/api/
|
||||
CORS_ORIGINS=https://<dominio-crm-produccion>
|
||||
|
||||
# --- PostgreSQL ---
|
||||
CORE_DB_HOST=postgres
|
||||
CORE_DB_PORT=5432
|
||||
CORE_DB_NAME=crm_core
|
||||
CORE_DB_USER=<usuario>
|
||||
POSTGRES_APP_PASSWORD=<password fuerte>
|
||||
|
||||
# --- MinIO / S3 ---
|
||||
S3_ENDPOINT_URL=http://minio:9000
|
||||
S3_ACCESS_KEY=<access>
|
||||
S3_SECRET_KEY=<secret>
|
||||
S3_BUCKET=crm
|
||||
S3_REGION=us-east-1
|
||||
S3_USE_SSL=false
|
||||
```
|
||||
|
||||
> **Importante:** `ENVIRONMENT=production` hace que el bootstrap de permisos solo dé
|
||||
> `super_admin` al **primer** usuario (no a todos). Es el comportamiento deseado en prod.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build del frontend — **en CI, no en el server**
|
||||
|
||||
La VM de prod no debe compilar el frontend (el build satura RAM). Compila la imagen
|
||||
en **Jenkins/CI** y publícala al registry, o compílala en una máquina de build:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend
|
||||
# push al registry interno si aplica
|
||||
```
|
||||
|
||||
El backend usa la imagen/código directamente (uvicorn sin --reload).
|
||||
|
||||
---
|
||||
|
||||
## 4. Despliegue
|
||||
|
||||
```bash
|
||||
# En el server de prod, en el directorio del proyecto:
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull # si usas registry
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
docker compose ps # verifica que todos queden healthy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Post-despliegue (datos) — **operador humano, con respaldo hecho**
|
||||
|
||||
1. **Verificar migraciones pendientes** (si el proyecto usa Alembic u otro):
|
||||
revisar y aplicar **solo** las que correspondan, con backup previo.
|
||||
2. **Seed base** (compañía por tenant + carriles), equivalente a `seed_crm.py`
|
||||
(`ensure_company` + roles Ventas/Operaciones/Facturación/Consulta).
|
||||
3. **Sync de permisos** (registra el catálogo por módulo):
|
||||
|
||||
```bash
|
||||
docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Verificación / smoke test
|
||||
|
||||
```bash
|
||||
curl -fsS https://<dominio-crm-produccion>/api/health && echo OK
|
||||
```
|
||||
|
||||
- [ ] Login vía **App Launcher del Workspace** entra sin bucle.
|
||||
- [ ] El dashboard carga compañías del tenant (switcher con el tenant correcto).
|
||||
- [ ] **Usuarios**: lista carga tras refrescar; alta por invitación crea enlace.
|
||||
- [ ] **Roles y permisos**: catálogo por módulo carga; marcar un permiso lo guarda
|
||||
(toast) y persiste al refrescar.
|
||||
- [ ] Licencia válida (sin bucle 401 / "sesión expirada").
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
1. `docker compose ... up -d` con la **imagen/tag anterior** del frontend/backend.
|
||||
2. Restaurar `.env` anterior si se cambió.
|
||||
3. Restaurar el backup de `crm_core` **solo** si hubo cambios de datos irreversibles.
|
||||
4. `SESSION_STORE_ENABLED=false` revierte al comportamiento previo de sesión sin
|
||||
redeploy de código (feature-flag), como mitigación rápida.
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
|
||||
64
frontend/src/lib/api/crm/catalogs.ts
Normal file
64
frontend/src/lib/api/crm/catalogs.ts
Normal 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>>
|
||||
};
|
||||
@@ -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) ----------
|
||||
|
||||
107
frontend/src/lib/api/crm/rates.ts
Normal file
107
frontend/src/lib/api/crm/rates.ts
Normal 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))
|
||||
};
|
||||
@@ -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[];
|
||||
|
||||
@@ -57,7 +57,9 @@ export const permissionsAPI = {
|
||||
if (params?.action) queryParams.set('action', params.action);
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
const query = queryParams.toString();
|
||||
const response = await api.get(`/v1/core/permissions/${query ? '?' + query : ''}`);
|
||||
// Sin slash final: la ruta backend es @router.get("") = /v1/core/permissions.
|
||||
// Con slash FastAPI responde 307 y el cliente server-side no lo sigue.
|
||||
const response = await api.get(`/v1/core/permissions${query ? '?' + query : ''}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -65,7 +67,7 @@ export const permissionsAPI = {
|
||||
* Obtener un permiso por ID
|
||||
*/
|
||||
async getById(id: number): Promise<Permission> {
|
||||
const response = await api.get(`/v1/core/permissions/${id}/`);
|
||||
const response = await api.get(`/v1/core/permissions/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -73,7 +75,7 @@ export const permissionsAPI = {
|
||||
* Crear un nuevo permiso
|
||||
*/
|
||||
async create(data: CreatePermissionData): Promise<Permission> {
|
||||
const response = await api.post('/v1/core/permissions/', data);
|
||||
const response = await api.post('/v1/core/permissions', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -81,7 +83,7 @@ export const permissionsAPI = {
|
||||
* Actualizar un permiso
|
||||
*/
|
||||
async update(id: number, data: UpdatePermissionData): Promise<Permission> {
|
||||
const response = await api.put(`/v1/core/permissions/${id}/`, data);
|
||||
const response = await api.put(`/v1/core/permissions/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -89,14 +91,14 @@ export const permissionsAPI = {
|
||||
* Eliminar un permiso
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/${id}/`);
|
||||
await api.delete(`/v1/core/permissions/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener módulos únicos
|
||||
*/
|
||||
async getModules(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/modules/');
|
||||
const response = await api.get('/v1/core/permissions/modules');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -104,7 +106,7 @@ export const permissionsAPI = {
|
||||
* Obtener acciones únicas
|
||||
*/
|
||||
async getActions(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/actions/');
|
||||
const response = await api.get('/v1/core/permissions/actions');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,7 +48,11 @@ export const rolePermissionsAPI = {
|
||||
companyId: number,
|
||||
data: AssignPermissionData
|
||||
): Promise<RolePermission> {
|
||||
const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data);
|
||||
// El backend recibe permission_id como query param (no en el body).
|
||||
const response = await api.post(
|
||||
`/v1/core/permissions/roles/${roleId}/permissions?permission_id=${data.permission_id}&company_id=${companyId}`,
|
||||
{}
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -407,10 +407,12 @@ export const initAuth = async (): Promise<boolean> => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sin token local, intentar Keycloak JS (flujo SSO)
|
||||
const authenticated = await initKeycloak();
|
||||
// Sin token local: no hay sesión en el cliente. El login entra SIEMPRE por
|
||||
// el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange);
|
||||
// el CRM NO inicializa Keycloak en el browser. Si no hay token, el layout
|
||||
// del servidor reenvía al Workspace.
|
||||
authStore.setLoading(false);
|
||||
return authenticated;
|
||||
return false;
|
||||
} catch (err) {
|
||||
console.error('[auth] Error en initAuth:', err);
|
||||
authStore.setLoading(false);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Briefcase,
|
||||
Ship,
|
||||
Receipt,
|
||||
Building2,
|
||||
Building,
|
||||
} from '@lucide/svelte';
|
||||
|
||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -71,13 +74,9 @@ export function getNavMain(): NavMainItem[] {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
url: '/dashboard/workspace/organizaciones',
|
||||
icon: Building2,
|
||||
items: [
|
||||
{ title: 'Organizaciones', url: '/dashboard/workspace/organizaciones' },
|
||||
{ title: 'Usuarios (invitaciones)', url: '/dashboard/workspace/usuarios' },
|
||||
],
|
||||
title: 'Compañías',
|
||||
url: '/dashboard/companias',
|
||||
icon: Building,
|
||||
},
|
||||
{
|
||||
title: 'Usuarios',
|
||||
@@ -93,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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -145,9 +145,18 @@
|
||||
>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
|
||||
{#if userTenants.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{#if companyStore.activeCompany?.tenant_name}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
|
||||
<BuildingIcon class="size-3.5" />
|
||||
</div>
|
||||
<span class="truncate font-medium">{companyStore.activeCompany.tenant_name}</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{:else}
|
||||
{#each userTenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
|
||||
@@ -5,9 +5,14 @@ const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com';
|
||||
const RETURN_PATH_COOKIE = 'workspace_return_path';
|
||||
|
||||
/**
|
||||
* Returns true only when the public-facing URL uses HTTPS.
|
||||
* Use this for cookie `secure` flag instead of NODE_ENV so that
|
||||
* cookies work on HTTP LAN dev environments (e.g. 192.168.x.x).
|
||||
* Autenticación 100% vía el Hub/Workspace (patrón SIWEB). El CRM NUNCA habla
|
||||
* directo a Keycloak: el login entra por el App Launcher del workspace (relay
|
||||
* → /auth/sso → Hub /sso-exchange) y el resto de auth va por la API del Hub.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True solo cuando la URL pública usa HTTPS. Se usa para el flag `secure` de las
|
||||
* cookies (en vez de NODE_ENV) para que funcionen en dev HTTP LAN (192.168.x.x).
|
||||
*/
|
||||
export function isSecureContext(): boolean {
|
||||
const origin = (env.ORIGIN || process.env.ORIGIN || '').trim();
|
||||
@@ -20,9 +25,8 @@ function stripTrailingSlashes(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si una URL apunta a un host que solo es accesible localmente:
|
||||
* localhost, 127.0.0.1, IPs de red LAN/privada y hostnames internos de Docker.
|
||||
* Estas URLs no son válidas como redirect_uri ni como KC public URL en producción.
|
||||
* Detecta URLs solo accesibles localmente (localhost, IPs LAN/privadas, hosts
|
||||
* internos de Docker). No son válidas como URL pública del Workspace.
|
||||
*/
|
||||
function isDevOnlyUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
@@ -60,27 +64,14 @@ export function getWorkspaceBaseUrl(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri seguros.
|
||||
*
|
||||
* Problema habitual en producción: SvelteKit deriva `url.origin` de la variable de entorno
|
||||
* `ORIGIN`. Si el contenedor se despliega con `ORIGIN=http://localhost:5173` (valor del .env
|
||||
* de dev), todos los redirect_uri generados por el servidor apuntan a localhost.
|
||||
*
|
||||
* Esta función:
|
||||
* 1. Usa `requestOrigin` si ya es una URL pública (no dev-only).
|
||||
* 2. Si es localhost, busca `SITE_URL` (env var de producción recomendada) como fallback.
|
||||
* 3. Como último recurso devuelve requestOrigin tal cual (entorno dev genuino).
|
||||
*
|
||||
* Var de entorno recomendada en producción:
|
||||
* SITE_URL=https://mi-app.dominio.com (además de arreglar ORIGIN)
|
||||
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri.
|
||||
* Corrige el caso donde `ORIGIN` env var apunta a localhost en producción.
|
||||
*/
|
||||
export function resolveSystemBaseUrl(requestOrigin: string): string {
|
||||
if (!isDevOnlyUrl(requestOrigin)) {
|
||||
return stripTrailingSlashes(requestOrigin);
|
||||
}
|
||||
|
||||
// requestOrigin es dev-only → ORIGIN env var apunta a localhost en producción.
|
||||
// Buscar URL pública en env vars adicionales.
|
||||
const candidates = [
|
||||
(env.SITE_URL || '').trim(),
|
||||
(env.APP_URL || '').trim(),
|
||||
@@ -93,31 +84,17 @@ export function resolveSystemBaseUrl(requestOrigin: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Entorno dev genuino: devolver requestOrigin tal cual
|
||||
return stripTrailingSlashes(requestOrigin);
|
||||
}
|
||||
|
||||
export type WorkspaceLoginUrlOptions = {
|
||||
/**
|
||||
* URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que,
|
||||
* tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps).
|
||||
* Con `return_to` a Mi Aplicación, el re-login siempre rebotaba a esa app aunque hubiera más.
|
||||
*/
|
||||
forPostLogout?: boolean;
|
||||
};
|
||||
|
||||
export function getWorkspaceLoginUrl(
|
||||
systemBaseUrl: string,
|
||||
options?: WorkspaceLoginUrlOptions
|
||||
): string {
|
||||
const workspaceBaseUrl = getWorkspaceBaseUrl();
|
||||
if (options?.forPostLogout) {
|
||||
return `${workspaceBaseUrl}/login`;
|
||||
}
|
||||
// return_to includes sso_verified=1 so the workspace preserves it when redirecting
|
||||
// back, regardless of what additional params the workspace appends.
|
||||
const loginUrl = `${systemBaseUrl}/login?sso_verified=1`;
|
||||
return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`;
|
||||
/**
|
||||
* URL de login del Workspace. NO lleva `return_to` a Mi Aplicación: el Hub
|
||||
* muestra el App Launcher y el usuario re-entra al CRM por relay
|
||||
* (→ /auth/sso?relay=). Así se evita el rebote a /login sin sesión (bucle) y no
|
||||
* se usa ningún flujo OIDC directo contra Keycloak.
|
||||
*/
|
||||
export function getWorkspaceLoginUrl(): string {
|
||||
return `${getWorkspaceBaseUrl()}/login`;
|
||||
}
|
||||
|
||||
export function storeReturnPath(cookies: Cookies, path: string): void {
|
||||
@@ -131,26 +108,6 @@ export function storeReturnPath(cookies: Cookies, path: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicKeycloakBaseUrl(): string {
|
||||
const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim();
|
||||
// Si VITE_KEYCLOAK_URL apunta a un host dev-only (localhost, IP LAN, Docker service),
|
||||
// ignorarlo y derivar la URL del hostname público del Workspace.
|
||||
// Esto protege contra builds donde el .env de dev llega a producción por error.
|
||||
if (configuredKeycloakUrl && !isDevOnlyUrl(configuredKeycloakUrl)) {
|
||||
return stripTrailingSlashes(configuredKeycloakUrl);
|
||||
}
|
||||
|
||||
return `${getWorkspaceBaseUrl()}/kcauth`;
|
||||
}
|
||||
|
||||
export function getKeycloakRealm(): string {
|
||||
return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim();
|
||||
}
|
||||
|
||||
export function getKeycloakClientId(): string {
|
||||
return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'app-frontend').trim();
|
||||
}
|
||||
|
||||
export function getCleanReturnPath(url: URL): string {
|
||||
const cleanParams = new URLSearchParams(url.searchParams);
|
||||
cleanParams.delete('sso_verified');
|
||||
@@ -186,68 +143,9 @@ export function clearWorkspaceReturnPath(cookies: Cookies): void {
|
||||
cookies.delete(RETURN_PATH_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
|
||||
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
|
||||
const redirectUri = `${publicBase}/auth/callback`;
|
||||
const state = JSON.stringify({ redirect_url: redirectPath });
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
prompt: 'none',
|
||||
state
|
||||
});
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye URL de login directo en KC sin prompt=none.
|
||||
* Usa la sesión KC existente si la hay; si no, muestra el form de login.
|
||||
* Usar cuando se recibe ?redirect= del Hub (rompe el loop Hub↔login).
|
||||
*/
|
||||
export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
|
||||
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
|
||||
const redirectUri = `${publicBase}/auth/callback`;
|
||||
const state = JSON.stringify({ redirect_url: redirectPath });
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
state
|
||||
});
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
|
||||
// Modo local: nunca salir al workspace, mostrar el login local.
|
||||
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
storeWorkspaceReturnPath(cookies, url);
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
|
||||
export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never {
|
||||
throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath));
|
||||
}
|
||||
|
||||
export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never {
|
||||
throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL del Hub FastAPI para llamadas server-to-server (ej. sso-exchange).
|
||||
* No aplica isDevOnlyUrl: las URLs internas de Docker son válidas aquí.
|
||||
* Lee HUB_BACKEND_URL (override explícito) → INTERNAL_HUB_URL (ya en docker-compose)
|
||||
* → fallback a URL pública del workspace (vía proxy SvelteKit del Hub).
|
||||
*/
|
||||
export function getHubBackendUrl(): string {
|
||||
const direct =
|
||||
@@ -257,19 +155,15 @@ export function getHubBackendUrl(): string {
|
||||
return getWorkspaceBaseUrl();
|
||||
}
|
||||
|
||||
export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
const postLogoutRedirectUri = `${systemBaseUrl}/auth/post-logout`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
post_logout_redirect_uri: postLogoutRedirectUri
|
||||
});
|
||||
|
||||
// Con id_token_hint KC acepta cualquier post_logout_redirect_uri sin necesidad
|
||||
// de que esté registrado explícitamente en el cliente.
|
||||
if (idTokenHint) {
|
||||
params.set('id_token_hint', idTokenHint);
|
||||
/**
|
||||
* Redirige al login del Workspace (App Launcher). Único punto de entrada de
|
||||
* login: el CRM no inicia ningún flujo contra Keycloak.
|
||||
*/
|
||||
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
|
||||
// Modo local: nunca salir al workspace, mostrar el login local.
|
||||
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`;
|
||||
}
|
||||
storeWorkspaceReturnPath(cookies, url);
|
||||
throw redirect(303, getWorkspaceLoginUrl());
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ interface Company {
|
||||
rfc?: string;
|
||||
logo?: string;
|
||||
tenant_id: number;
|
||||
tenant_name?: string;
|
||||
tenant_slug?: string;
|
||||
}
|
||||
|
||||
class CompanyStore {
|
||||
@@ -158,7 +160,9 @@ class CompanyStore {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
// tenant_id de la compañía → override para que el backend escale por
|
||||
// ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token).
|
||||
body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }),
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
77
frontend/src/lib/stores/crm-catalogs.svelte.ts
Normal file
77
frontend/src/lib/stores/crm-catalogs.svelte.ts
Normal 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();
|
||||
@@ -6,22 +6,28 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
try {
|
||||
const { companyId } = await request.json();
|
||||
const body = await request.json();
|
||||
const companyId = body?.companyId;
|
||||
const tenantId = body?.tenantId;
|
||||
|
||||
if (!companyId || typeof companyId !== 'number') {
|
||||
return json({ error: 'Invalid company ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Establecer la cookie desde el servidor
|
||||
cookies.set('active_company_id', companyId.toString(), {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 días
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // Permitir acceso desde JavaScript
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd };
|
||||
|
||||
return json({ success: true, companyId });
|
||||
// Compañía activa (legible desde JS)
|
||||
cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false });
|
||||
|
||||
// Fijar el tenant de la compañía como override → el backend escala por ese
|
||||
// tenant aunque el token no lo traiga (caso hub_admin operando por compañía).
|
||||
if (typeof tenantId === 'number' && Number.isFinite(tenantId)) {
|
||||
cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true });
|
||||
cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false });
|
||||
}
|
||||
|
||||
return json({ success: true, companyId, tenantId: tenantId ?? null });
|
||||
} catch (error) {
|
||||
console.error('Error setting active company:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -1,132 +1,15 @@
|
||||
import { redirect, isRedirect } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
clearWorkspaceReturnPath,
|
||||
getWorkspaceLoginUrl,
|
||||
readWorkspaceReturnPath,
|
||||
storeReturnPath,
|
||||
} from '$lib/server/workspace-auth';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
// Obtener el código y state de los query params
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
if (errorParam) {
|
||||
console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription);
|
||||
// login_required means no KC session exists yet → send to Workspace login.
|
||||
// Preserve the intended destination through the detour so /login can pick it up.
|
||||
if (state) {
|
||||
try {
|
||||
const stateObj = JSON.parse(state);
|
||||
const returnPath = stateObj.redirect_url;
|
||||
if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') {
|
||||
storeReturnPath(cookies, returnPath);
|
||||
}
|
||||
} catch { /* ignore malformed state */ }
|
||||
}
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
console.error('❌ [Callback Server] No se recibió código de autorización');
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
|
||||
try {
|
||||
// Intercambiar código por tokens usando el backend de Keycloak
|
||||
// En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080
|
||||
// En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback
|
||||
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080';
|
||||
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master';
|
||||
const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend';
|
||||
const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
|
||||
|
||||
// La redirect_uri debe coincidir exactamente con la registrada en Keycloak.
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque
|
||||
// ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback).
|
||||
const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth');
|
||||
const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`;
|
||||
|
||||
const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: KEYCLOAK_CLIENT_ID,
|
||||
...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET })
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: body.toString()
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.text();
|
||||
console.error('❌ [Callback Server] Error al intercambiar código:', errorData);
|
||||
throw new Error('Error al obtener tokens');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Establecer las cookies en el servidor
|
||||
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
|
||||
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
|
||||
const { isSecureContext } = await import('$lib/server/workspace-auth');
|
||||
const isProduction = isSecureContext();
|
||||
|
||||
setAccessTokenCookies(cookies, tokens.access_token, {
|
||||
secure: isProduction,
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 días
|
||||
});
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
cookies.set('refresh_token', tokens.refresh_token, {
|
||||
path: '/',
|
||||
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 días
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.id_token) {
|
||||
cookies.set('id_token', tokens.id_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener la URL de redirección del state o ir al dashboard
|
||||
let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard');
|
||||
if (state) {
|
||||
try {
|
||||
const stateObj = JSON.parse(state);
|
||||
redirectTo = stateObj.redirect_url || '/dashboard';
|
||||
} catch (e) {
|
||||
console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state');
|
||||
}
|
||||
}
|
||||
|
||||
clearWorkspaceReturnPath(cookies);
|
||||
|
||||
// Redirigir a la página de destino
|
||||
throw redirect(303, redirectTo);
|
||||
|
||||
} catch (err: any) {
|
||||
if (isRedirect(err)) throw err;
|
||||
console.error('❌ [Callback Server] Error procesando autenticación:', err);
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
/**
|
||||
* Callback OIDC — OBSOLETO.
|
||||
*
|
||||
* El CRM ya no inicia flujo de autorización contra Keycloak: el login entra por
|
||||
* el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange). Esta
|
||||
* ruta se conserva solo para no romper enlaces viejos; cualquier acceso se
|
||||
* redirige al dashboard (el layout valida la sesión y, si no hay, reenvía al
|
||||
* Workspace). No se intercambia ningún `code` con Keycloak.
|
||||
*/
|
||||
export const load: PageServerLoad = async () => {
|
||||
throw redirect(303, '/dashboard');
|
||||
};
|
||||
|
||||
@@ -3,11 +3,10 @@ import type { RequestHandler } from './$types';
|
||||
import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
|
||||
|
||||
/**
|
||||
* KC redirects here after completing the logout flow.
|
||||
* This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*).
|
||||
* We then send the user to workspace login so it can apply myApps() launcher logic.
|
||||
* Ruta de retorno post-logout. El CRM ya no dispara logout contra Keycloak
|
||||
* (el cierre completo se hace desde el Workspace); se conserva por compatibilidad
|
||||
* y redirige al App Launcher del Workspace.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const systemBaseUrl = url.origin;
|
||||
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
|
||||
export const GET: RequestHandler = async () => {
|
||||
throw redirect(303, getWorkspaceLoginUrl());
|
||||
};
|
||||
|
||||
157
frontend/src/routes/dashboard/companias/+page.svelte
Normal file
157
frontend/src/routes/dashboard/companias/+page.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Building, Plus } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { api } from '$lib/api';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
type Tenant = { id: number; name: string; slug: string };
|
||||
|
||||
let tenants = $state<Tenant[]>([]);
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
let name = $state('');
|
||||
let rfc = $state('');
|
||||
let tenantId = $state<number | null>(null);
|
||||
|
||||
const companies = $derived(companyStore.companies);
|
||||
|
||||
onMount(async () => {
|
||||
const res = await api.get<Tenant[]>('/v1/auth/assignable-tenants');
|
||||
if (res.data) {
|
||||
tenants = res.data;
|
||||
if (tenants.length === 1) tenantId = tenants[0].id;
|
||||
}
|
||||
await companyStore.loadCompanies();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function createCompany() {
|
||||
if (name.trim().length < 2) {
|
||||
toast.error('El nombre de la compañía es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (!tenantId) {
|
||||
toast.error('Selecciona el tenant al que pertenece');
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await api.post<{ id: number }>('/v1/auth/companies', {
|
||||
name: name.trim(),
|
||||
tenant_id: tenantId,
|
||||
rfc: rfc.trim() || null
|
||||
});
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Compañía creada');
|
||||
name = '';
|
||||
rfc = '';
|
||||
await companyStore.loadCompanies();
|
||||
// Seleccionarla como activa para poder trabajar de inmediato.
|
||||
const created = res.data?.id
|
||||
? companyStore.companies.find((c) => c.id === res.data!.id)
|
||||
: null;
|
||||
if (created) await companyStore.setActiveCompany(created);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo crear la compañía');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Building class="h-6 w-6" /> Compañías
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del
|
||||
Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva compañía</Card.Title>
|
||||
<Card.Description>El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa.</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">Nombre / Razón social *</span>
|
||||
<input class={inputCls} bind:value={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={rfc} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Tenant (Workspace) *</span>
|
||||
<select class={inputCls} bind:value={tenantId}>
|
||||
<option value={null} disabled>Selecciona…</option>
|
||||
{#each tenants as t (t.id)}
|
||||
<option value={t.id}>{t.name} ({t.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button onclick={createCompany} disabled={submitting}>
|
||||
{submitting ? 'Creando…' : 'Crear compañía'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Compañías ({companies.length})</Card.Title>
|
||||
<Card.Description>Empresas a las que tienes acceso.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if companies.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Aún no tienes compañías. Crea una arriba.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4 font-medium">Nombre</th>
|
||||
<th class="py-2 pr-4 font-medium">RFC</th>
|
||||
<th class="py-2 pr-4 font-medium">Tenant</th>
|
||||
<th class="py-2 pr-4 font-medium">Activa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each companies as c (c.id)}
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{c.name}</td>
|
||||
<td class="py-2 pr-4 font-mono text-xs">{c.rfc ?? '—'}</td>
|
||||
<td class="py-2 pr-4">{c.tenant_id}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{#if companyStore.activeCompany?.id === c.id}
|
||||
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-600">activa</span>
|
||||
{:else}
|
||||
<button class="text-xs text-primary hover:underline" onclick={() => companyStore.setActiveCompany(c)}>usar</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
217
frontend/src/routes/dashboard/crm/catalogos/+page.svelte
Normal file
217
frontend/src/routes/dashboard/crm/catalogos/+page.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
113
frontend/src/routes/dashboard/crm/cotizador/+page.svelte
Normal file
113
frontend/src/routes/dashboard/crm/cotizador/+page.svelte
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
179
frontend/src/routes/dashboard/crm/tarifarios/+page.svelte
Normal file
179
frontend/src/routes/dashboard/crm/tarifarios/+page.svelte
Normal 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}
|
||||
253
frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte
Normal file
253
frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte
Normal 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}
|
||||
@@ -1,26 +1,231 @@
|
||||
<script lang="ts">
|
||||
import { Shield } from 'lucide-svelte';
|
||||
import { Shield, Plus, Trash2, ChevronRight, ChevronDown } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
|
||||
import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions';
|
||||
import { rolePermissionsAPI } from '$lib/api/dashboard/admin/role-permissions';
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
let roles = $state<CompanyRole[]>([]);
|
||||
let catalog = $state<Permission[]>([]);
|
||||
let loading = $state(true);
|
||||
let creating = $state(false);
|
||||
let name = $state('');
|
||||
let code = $state('');
|
||||
let description = $state('');
|
||||
let expandedRoleId = $state<number | null>(null);
|
||||
// roleId → Set de permission_id asignados
|
||||
let rolePerms = $state<Record<number, Set<number>>>({});
|
||||
let savingPerm = $state<string | null>(null);
|
||||
|
||||
// Catálogo agrupado por módulo
|
||||
const byModule = $derived.by(() => {
|
||||
const m: Record<string, Permission[]> = {};
|
||||
for (const p of catalog) (m[p.module] ??= []).push(p);
|
||||
return m;
|
||||
});
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!companyId) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const [rRes, pRes] = await Promise.all([
|
||||
rolesAPI.list(companyId, { page_size: 100 }),
|
||||
permissionsAPI.list({ page_size: 1000 })
|
||||
]);
|
||||
roles = rRes.data?.items ?? [];
|
||||
catalog = pRes.items ?? [];
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar roles/permisos');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recarga los roles cuando cambia la compañía activa —incluida la hidratación
|
||||
// inicial tras refrescar (en onMount el store aún no tenía compañía, por eso
|
||||
// antes no aparecían hasta re-seleccionarla).
|
||||
$effect(() => {
|
||||
const id = companyId;
|
||||
expandedRoleId = null;
|
||||
rolePerms = {};
|
||||
if (id) {
|
||||
void load();
|
||||
} else {
|
||||
roles = [];
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRole() {
|
||||
if (!companyId) return;
|
||||
const n = name.trim();
|
||||
if (n.length < 2) {
|
||||
toast.error('El nombre del rol es obligatorio');
|
||||
return;
|
||||
}
|
||||
const c = (code.trim() || slugify(n));
|
||||
creating = true;
|
||||
try {
|
||||
const res = await rolesAPI.create(companyId, { name: n, code: c, description: description.trim() || undefined });
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Rol creado');
|
||||
name = '';
|
||||
code = '';
|
||||
description = '';
|
||||
await load();
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function delRole(r: CompanyRole) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar el rol "${r.name}"?`)) return;
|
||||
const res = await rolesAPI.delete(r.id, companyId);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Rol eliminado');
|
||||
await load();
|
||||
}
|
||||
|
||||
async function toggleExpand(r: CompanyRole) {
|
||||
if (expandedRoleId === r.id) {
|
||||
expandedRoleId = null;
|
||||
return;
|
||||
}
|
||||
expandedRoleId = r.id;
|
||||
if (!rolePerms[r.id] && companyId) {
|
||||
try {
|
||||
const resp = await rolePermissionsAPI.listByRole(r.id, companyId);
|
||||
rolePerms = { ...rolePerms, [r.id]: new Set((resp.permissions ?? []).map((p) => p.permission_id)) };
|
||||
} catch {
|
||||
rolePerms = { ...rolePerms, [r.id]: new Set() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePerm(roleId: number, perm: Permission, checked: boolean) {
|
||||
if (!companyId) return;
|
||||
savingPerm = `${roleId}:${perm.id}`;
|
||||
try {
|
||||
if (checked) {
|
||||
await rolePermissionsAPI.assign(roleId, companyId, { permission_id: perm.id });
|
||||
rolePerms[roleId]?.add(perm.id);
|
||||
} else {
|
||||
await rolePermissionsAPI.remove(roleId, perm.id, companyId);
|
||||
rolePerms[roleId]?.delete(perm.id);
|
||||
}
|
||||
rolePerms = { ...rolePerms };
|
||||
toast.success(checked ? 'Permiso agregado' : 'Permiso quitado');
|
||||
} catch (e) {
|
||||
toast.error('No se pudo actualizar el permiso');
|
||||
} finally {
|
||||
savingPerm = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Shield class="h-6 w-6" />
|
||||
Roles y permisos
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Shield class="h-6 w-6" /> Roles y permisos
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">
|
||||
Gestión de roles y control de acceso.
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Define roles (carriles) por compañía y qué puede hacer cada uno. Los usuarios se asignan en Usuarios.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Roles del sistema</Card.Title>
|
||||
<Card.Description>Implementa aquí la gestión de roles y permisos de tu proyecto.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{#if !companyId}
|
||||
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa para gestionar sus roles.</Card.Content></Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nuevo rol</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">Nombre *</span><input class={inputCls} bind:value={name} placeholder="Ventas" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Código</span><input class="font-mono {inputCls}" bind:value={code} placeholder="(auto)" title="Se genera del nombre si lo dejas vacío" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={description} /></label>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end"><Button onclick={createRole} disabled={creating}>{creating ? 'Creando…' : 'Crear rol'}</Button></div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Roles ({roles.length})</Card.Title>
|
||||
<Card.Description>Haz clic en un rol para ver y editar sus permisos.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-2">
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if roles.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No hay roles. Crea uno arriba.</p>
|
||||
{:else}
|
||||
{#each roles as r (r.id)}
|
||||
<div class="rounded-md border">
|
||||
<div class="flex items-center gap-2 px-3 py-2">
|
||||
<button type="button" class="flex flex-1 items-center gap-2 text-left" onclick={() => toggleExpand(r)}>
|
||||
{#if expandedRoleId === r.id}<ChevronDown class="h-4 w-4" />{:else}<ChevronRight class="h-4 w-4" />{/if}
|
||||
<span class="font-medium">{r.name}</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">{r.code}</span>
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" onclick={() => delRole(r)}><Trash2 class="h-4 w-4 text-destructive" /></Button>
|
||||
</div>
|
||||
{#if expandedRoleId === r.id}
|
||||
<div class="border-t p-3">
|
||||
{#if !rolePerms[r.id]}
|
||||
<p class="text-sm text-muted-foreground">Cargando permisos…</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each Object.entries(byModule) as [mod, perms] (mod)}
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{mod}</p>
|
||||
<div class="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each perms as p (p.id)}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border"
|
||||
checked={rolePerms[r.id].has(p.id)}
|
||||
disabled={savingPerm === `${r.id}:${p.id}`}
|
||||
onchange={(e) => togglePerm(r.id, p, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span title={p.code}>{p.description || p.code}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
104
frontend/src/routes/dashboard/settings/cotizacion/+page.svelte
Normal file
104
frontend/src/routes/dashboard/settings/cotizacion/+page.svelte
Normal 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>
|
||||
@@ -1,23 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { Users } from 'lucide-svelte';
|
||||
import { Users, X, UserPlus, Copy, Check } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { usersAPI, type User } from '$lib/api/dashboard/users';
|
||||
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
|
||||
import { userRolesAPI, type UserRole } from '$lib/api/dashboard/admin/user-roles';
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let roles = $state<CompanyRole[]>([]);
|
||||
let assignments = $state<UserRole[]>([]);
|
||||
let loading = $state(true);
|
||||
let busy = $state<string | null>(null);
|
||||
|
||||
// Alta de usuario por invitación
|
||||
let inviteEmail = $state('');
|
||||
let inviteRoleId = $state<number | null>(null);
|
||||
let inviting = $state(false);
|
||||
let inviteUrl = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
// user_id → asignaciones de rol
|
||||
const rolesByUser = $derived.by(() => {
|
||||
const m: Record<string, UserRole[]> = {};
|
||||
for (const a of assignments) (m[String(a.user_id)] ??= []).push(a);
|
||||
return m;
|
||||
});
|
||||
|
||||
function fullName(u: User): string {
|
||||
const n = `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim();
|
||||
return n || u.username || u.email || u.id;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!companyId) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const [uRes, rRes, arRes] = await Promise.all([
|
||||
usersAPI.list(companyId, { page_size: 100 }).catch(() => ({ users: [] as User[] })),
|
||||
rolesAPI.list(companyId, { page_size: 100 }),
|
||||
userRolesAPI.list(companyId, { page_size: 500 }).catch(() => ({ items: [] as UserRole[] } as any))
|
||||
]);
|
||||
users = uRes.users ?? [];
|
||||
roles = rRes.data?.items ?? [];
|
||||
assignments = arRes.items ?? [];
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar los usuarios');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recarga al cambiar la compañía activa —incluida la hidratación inicial tras
|
||||
// refrescar (antes no cargaba hasta re-seleccionar la compañía).
|
||||
$effect(() => {
|
||||
const id = companyId;
|
||||
if (id) {
|
||||
void load();
|
||||
} else {
|
||||
users = [];
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function assignRole(user: User, roleIdRaw: string) {
|
||||
if (!companyId) return;
|
||||
const roleId = Number(roleIdRaw);
|
||||
if (!roleId) return;
|
||||
if (rolesByUser[String(user.id)]?.some((a) => a.company_role_id === roleId)) {
|
||||
toast.info('El usuario ya tiene ese rol');
|
||||
return;
|
||||
}
|
||||
busy = `assign:${user.id}`;
|
||||
try {
|
||||
await userRolesAPI.assign(companyId, { user_id: String(user.id), company_role_id: roleId });
|
||||
toast.success('Rol asignado');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo asignar el rol');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRole(a: UserRole) {
|
||||
if (!companyId) return;
|
||||
busy = `remove:${a.id}`;
|
||||
try {
|
||||
await userRolesAPI.remove(a.id, companyId);
|
||||
toast.success('Rol removido');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo remover el rol');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteUser() {
|
||||
if (!companyId) return;
|
||||
const email = inviteEmail.trim();
|
||||
if (!email || !email.includes('@')) {
|
||||
toast.error('Ingresa un email válido');
|
||||
return;
|
||||
}
|
||||
if (!inviteRoleId) {
|
||||
toast.error('Selecciona el rol del usuario');
|
||||
return;
|
||||
}
|
||||
inviting = true;
|
||||
inviteUrl = null;
|
||||
try {
|
||||
const res = await usersAPI.invite({ email, company_id: companyId, role_id: inviteRoleId });
|
||||
toast.success('Invitación creada');
|
||||
inviteUrl = res.invite_url ?? null;
|
||||
inviteEmail = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo dar de alta el usuario');
|
||||
} finally {
|
||||
inviting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
if (!inviteUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
copied = true;
|
||||
toast.success('Enlace copiado');
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
toast.error('No se pudo copiar');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Users class="h-6 w-6" />
|
||||
Usuarios
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Users class="h-6 w-6" /> Usuarios
|
||||
</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Gestión de usuarios y accesos.</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Usuarios de la compañía activa y sus roles. Para dar de alta usuarios nuevos usa Workspace → Usuarios (invitaciones).
|
||||
</p>
|
||||
</div>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Usuarios del sistema</Card.Title>
|
||||
<Card.Description>Implementa aquí la gestión de usuarios de tu proyecto.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
{#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="flex items-center gap-2"><UserPlus class="h-4 w-4" /> Dar de alta usuario</Card.Title>
|
||||
<Card.Description>Se envía una invitación por email; el usuario crea su contraseña y queda en la compañía con el rol elegido.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Email *</span>
|
||||
<input type="email" class={inputCls} bind:value={inviteEmail} placeholder="persona@empresa.com" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Rol *</span>
|
||||
<select class={inputCls} bind:value={inviteRoleId}>
|
||||
<option value={null} disabled>Selecciona…</option>
|
||||
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between gap-2">
|
||||
{#if roles.length === 0}
|
||||
<span class="text-xs text-muted-foreground">Primero crea roles en "Roles y permisos".</span>
|
||||
{:else}<span></span>{/if}
|
||||
<Button onclick={inviteUser} disabled={inviting || roles.length === 0}>{inviting ? 'Enviando…' : 'Invitar / dar de alta'}</Button>
|
||||
</div>
|
||||
{#if inviteUrl}
|
||||
<div class="mt-3 flex items-center gap-2 rounded-md border bg-muted/40 p-2">
|
||||
<span class="whitespace-nowrap text-xs text-muted-foreground">Si el correo no llega, comparte:</span>
|
||||
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={inviteUrl} />
|
||||
<Button variant="outline" size="sm" onclick={copyInvite}>{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Usuarios ({users.length})</Card.Title>
|
||||
<Card.Description>Asigna o quita roles (los roles se definen en Roles y permisos).</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if users.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No hay usuarios en esta compañía todavía.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4 font-medium">Usuario</th>
|
||||
<th class="py-2 pr-4 font-medium">Email</th>
|
||||
<th class="py-2 pr-4 font-medium">Roles</th>
|
||||
<th class="py-2 pr-4 font-medium">Asignar rol</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as u (u.id)}
|
||||
<tr class="border-b last:border-0 align-top">
|
||||
<td class="py-2 pr-4">{fullName(u)}</td>
|
||||
<td class="py-2 pr-4 text-muted-foreground">{u.email ?? '—'}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each rolesByUser[String(u.id)] ?? [] as a (a.id)}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
{a.company_role?.name ?? a.company_role?.code ?? `rol ${a.company_role_id}`}
|
||||
<button type="button" class="hover:text-destructive" disabled={busy === `remove:${a.id}`} onclick={() => removeRole(a)} title="Quitar rol"><X class="h-3 w-3" /></button>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">sin rol</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<select
|
||||
class={inputCls}
|
||||
disabled={busy === `assign:${u.id}` || roles.length === 0}
|
||||
onchange={(e) => { const el = e.currentTarget as HTMLSelectElement; assignRole(u, el.value); el.value = ''; }}
|
||||
>
|
||||
<option value="">+ rol…</option>
|
||||
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { getKcAccessToken } from '$lib/server/api';
|
||||
import { listWorkspaceTenants, createWorkspaceInvite } from '$lib/server/workspace-provision';
|
||||
import { getUserCompanies } from '$lib/server/api';
|
||||
import { createWorkspaceInvite } from '$lib/server/workspace-provision';
|
||||
import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch }) => {
|
||||
const accessToken = getKcAccessToken(cookies);
|
||||
if (!accessToken) {
|
||||
return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES };
|
||||
}
|
||||
// La organización destino sale de las compañías del usuario (mismo origen que
|
||||
// el switcher, vía /v1/auth/my-companies). Así no se escribe el slug a mano ni
|
||||
// se depende de listar todos los tenants del Hub con un token KC que caduca.
|
||||
const companiesRaw = await getUserCompanies(cookies, fetch);
|
||||
const companies = (companiesRaw ?? [])
|
||||
.filter((c) => c && c.tenant_slug)
|
||||
.map((c) => ({
|
||||
id: c.id as number,
|
||||
name: c.name as string,
|
||||
tenant_slug: c.tenant_slug as string,
|
||||
tenant_name: (c.tenant_name as string) || (c.tenant_slug as string)
|
||||
}));
|
||||
|
||||
// Los hub_admin pueden listar todos los tenants (dropdown). Un admin de tenant
|
||||
// no puede listarlos (403) pero sí puede invitar a SU tenant escribiendo el slug.
|
||||
const res = await listWorkspaceTenants(accessToken, fetch);
|
||||
if (!res.ok) {
|
||||
return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES };
|
||||
}
|
||||
|
||||
return {
|
||||
tenants: res.data.map((t) => ({ slug: t.slug, name: t.display_name || t.name })),
|
||||
canListTenants: true,
|
||||
roles: WORKSPACE_INVITE_ROLES
|
||||
};
|
||||
return { companies, roles: WORKSPACE_INVITE_ROLES };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -42,7 +39,7 @@ export const actions: Actions = {
|
||||
return fail(422, { error: 'Ingresa un email válido para invitar.', values });
|
||||
}
|
||||
if (!tenant_slug) {
|
||||
return fail(422, { error: 'Selecciona (o escribe) el slug de la organización destino.', values });
|
||||
return fail(422, { error: 'Selecciona la compañía destino.', values });
|
||||
}
|
||||
if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) {
|
||||
return fail(422, { error: 'Rol inválido.', values });
|
||||
|
||||
@@ -75,13 +75,7 @@
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Nueva invitación</Card.Title>
|
||||
<Card.Description>
|
||||
{#if data.canListTenants}
|
||||
Elige la organización destino y el rol del usuario.
|
||||
{:else}
|
||||
Escribe el slug de tu organización y el rol del usuario.
|
||||
{/if}
|
||||
</Card.Description>
|
||||
<Card.Description>Elige la compañía destino y el rol del usuario.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
@@ -110,24 +104,19 @@
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Organización *</span>
|
||||
{#if data.canListTenants}
|
||||
<span class="font-medium">Compañía destino *</span>
|
||||
{#if data.companies.length > 0}
|
||||
<select class={inputCls} name="tenant_slug" required value={prev('tenant_slug')}>
|
||||
<option value="" disabled>Selecciona…</option>
|
||||
{#each data.tenants as t (t.slug)}
|
||||
<option value={t.slug}>{t.name} ({t.slug})</option>
|
||||
{#each data.companies as c (c.id)}
|
||||
<option value={c.tenant_slug}>{c.name} — {c.tenant_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
class="font-mono {inputCls}"
|
||||
name="tenant_slug"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
placeholder="empresa-abc"
|
||||
title="Slug del tenant (minúsculas, dígitos y guiones)"
|
||||
value={prev('tenant_slug')}
|
||||
/>
|
||||
<span class="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
|
||||
No hay compañías disponibles en tu tenant. Crea una en Compañías.
|
||||
</span>
|
||||
<input type="hidden" name="tenant_slug" value="" />
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
@@ -142,7 +131,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
<Button type="submit" disabled={submitting || data.companies.length === 0}>
|
||||
{submitting ? 'Enviando…' : 'Enviar invitación'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { getServerApiUrl, getAuthTokens } from '$lib/server/api';
|
||||
import { redirectToKeycloakLogin } from '$lib/server/workspace-auth';
|
||||
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
const code = url.searchParams.get('code')?.toUpperCase().trim() ?? '';
|
||||
@@ -13,7 +13,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
|
||||
if (!accessToken) {
|
||||
// Sesión KC expiró entre redirecciones — volver a auth
|
||||
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
|
||||
redirectToWorkspaceLogin(cookies, url);
|
||||
}
|
||||
|
||||
const apiUrl = getServerApiUrl();
|
||||
@@ -76,7 +76,7 @@ export const actions: Actions = {
|
||||
|
||||
if (!accessToken) {
|
||||
// Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume
|
||||
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
|
||||
redirectToWorkspaceLogin(cookies, url);
|
||||
}
|
||||
|
||||
// Si ya hay sesión, consumir directamente vía redirect a step=consume
|
||||
|
||||
@@ -4,12 +4,9 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { clearAuthTokens, getAuthTokens } from '$lib/server/api';
|
||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
getWorkspaceLoginUrl,
|
||||
readWorkspaceReturnPath,
|
||||
clearWorkspaceReturnPath,
|
||||
storeReturnPath,
|
||||
redirectToKeycloakAuthorization,
|
||||
redirectToKeycloakLogin,
|
||||
redirectToWorkspaceLogin,
|
||||
getHubBackendUrl,
|
||||
isSecureContext,
|
||||
} from '$lib/server/workspace-auth';
|
||||
@@ -65,29 +62,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
|
||||
clearAuthTokens(cookies);
|
||||
|
||||
// Vuelta desde el Workspace tras autenticarse: el CRM NO inicia ningún flujo
|
||||
// OIDC contra Keycloak. La sesión se obtiene por relay del App Launcher
|
||||
// (→ /auth/sso). Si el usuario llega aquí ya autenticado en el Hub, se le
|
||||
// manda al dashboard; si no hay sesión local, el layout lo reenvía al
|
||||
// Workspace (App Launcher) para re-entrar por relay.
|
||||
if (url.searchParams.get('sso_verified') === '1') {
|
||||
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
|
||||
const intendedPath =
|
||||
existingReturnPath && existingReturnPath !== '/login'
|
||||
? existingReturnPath
|
||||
: (url.searchParams.get('redirect') || '/dashboard');
|
||||
storeReturnPath(cookies, intendedPath);
|
||||
redirectToKeycloakAuthorization(url.origin, intendedPath);
|
||||
throw redirect(303, '/dashboard');
|
||||
}
|
||||
|
||||
const redirectParam = url.searchParams.get('redirect');
|
||||
if (redirectParam) {
|
||||
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
|
||||
const intendedPath =
|
||||
existingReturnPath && existingReturnPath !== '/login'
|
||||
? existingReturnPath
|
||||
: redirectParam;
|
||||
storeReturnPath(cookies, intendedPath);
|
||||
redirectToKeycloakLogin(url.origin, intendedPath);
|
||||
}
|
||||
|
||||
storeReturnPath(cookies, '/dashboard');
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
// Sin sesión → App Launcher del Workspace (relay). Nunca Keycloak directo.
|
||||
redirectToWorkspaceLogin(cookies, url);
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -1,37 +1,28 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
buildKeycloakLogoutUrl,
|
||||
clearWorkspaceReturnPath,
|
||||
getWorkspaceLoginUrl
|
||||
} from '$lib/server/workspace-auth';
|
||||
import { clearAuthTokens } from '$lib/server/api';
|
||||
import { clearWorkspaceReturnPath, getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, url }) => {
|
||||
const systemBaseUrl = url.origin;
|
||||
|
||||
const idToken = cookies.get('id_token');
|
||||
|
||||
// Eliminar todas las cookies de autenticación
|
||||
clearAccessTokenCookies(cookies);
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
/**
|
||||
* Logout del CRM. Limpia la sesión LOCAL (cookies) y devuelve al Workspace.
|
||||
* NO habla directo a Keycloak: el cierre de sesión completo (Hub/KC) se hace
|
||||
* desde el Workspace. Patrón SIWEB.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
// Eliminar todas las cookies de autenticación (incluye sesión local + token KC)
|
||||
clearAuthTokens(cookies);
|
||||
cookies.delete('id_token', { path: '/' });
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
cookies.delete('active_system', { path: '/' });
|
||||
cookies.delete('sso_tenant_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||
clearWorkspaceReturnPath(cookies);
|
||||
|
||||
// En modo local no hay Keycloak ni Hub — ir directo al login local.
|
||||
// Modo local: no hay Workspace — ir al login local.
|
||||
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
// Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado.
|
||||
if (!idToken) {
|
||||
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
|
||||
}
|
||||
|
||||
throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken));
|
||||
// Volver al Workspace (App Launcher). Para cerrar la sesión del Hub por
|
||||
// completo, el usuario cierra sesión desde el Workspace.
|
||||
throw redirect(303, getWorkspaceLoginUrl());
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user