diff --git a/backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py b/backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py new file mode 100644 index 0000000..33673c9 --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_case_expediente.py @@ -0,0 +1,75 @@ +"""Expediente (crm.cases) + case_id en el ciclo comercial + +Revision ID: d4e5f6a7b8c9 +Revises: f0a1b2c3d4e5 +Create Date: 2026-08-07 02:00:00.000000 + +Crea crm.cases (expediente, hilo maestro con folio EXP...) y agrega case_id a +crm.opportunities/service_requests/quotes, ops.shipments y fin.invoices. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "d4e5f6a7b8c9" +down_revision: Union[str, None] = "f0a1b2c3d4e5" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# (schema, tabla) donde se agrega case_id +_CASE_FK_TABLES = [ + ("crm", "opportunities"), + ("crm", "service_requests"), + ("crm", "quotes"), + ("ops", "shipments"), + ("fin", "invoices"), +] + + +def upgrade() -> None: + op.create_table( + "cases", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("reference", sa.String(length=40), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("title", sa.String(length=255), nullable=True), + sa.Column("stage", sa.String(length=20), nullable=False, server_default=sa.text("'oportunidad'")), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")), + sa.Column("created_by", sa.String(length=64), nullable=True), + sa.Column("updated_by", sa.String(length=64), 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"]), + sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]), + schema="crm", + ) + op.create_index("ix_crm_cases_id", "cases", ["id"], schema="crm") + op.create_index("ix_crm_cases_reference", "cases", ["reference"], schema="crm") + op.create_index("ix_crm_cases_tenant_id", "cases", ["tenant_id"], schema="crm") + op.create_index("ix_crm_cases_company_id", "cases", ["company_id"], schema="crm") + op.create_index("ix_crm_cases_account_id", "cases", ["account_id"], schema="crm") + op.create_index("ix_crm_cases_status", "cases", ["status"], schema="crm") + + for schema, table in _CASE_FK_TABLES: + op.add_column(table, sa.Column("case_id", sa.Integer(), nullable=True), schema=schema) + op.create_index(f"ix_{schema}_{table}_case_id", table, ["case_id"], schema=schema) + op.create_foreign_key( + f"fk_{schema}_{table}_case_id", table, "cases", + ["case_id"], ["id"], source_schema=schema, referent_schema="crm", + ) + + +def downgrade() -> None: + for schema, table in _CASE_FK_TABLES: + op.drop_constraint(f"fk_{schema}_{table}_case_id", table, schema=schema, type_="foreignkey") + op.drop_index(f"ix_{schema}_{table}_case_id", table_name=table, schema=schema) + op.drop_column(table, "case_id", schema=schema) + + for idx in ("status", "account_id", "company_id", "tenant_id", "reference", "id"): + op.drop_index(f"ix_crm_cases_{idx}", table_name="cases", schema="crm") + op.drop_table("cases", schema="crm") diff --git a/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py new file mode 100644 index 0000000..6504d2a --- /dev/null +++ b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py @@ -0,0 +1,25 @@ +"""Medio de contacto preferido en el prospecto (lead) + +Revision ID: f0a1b2c3d4e5 +Revises: e4f5a6b7c8d9 +Create Date: 2026-08-07 01:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "f0a1b2c3d4e5" +down_revision: Union[str, None] = "e4f5a6b7c8d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column("leads", "preferred_contact_method", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/cases/__init__.py b/backend/api/v1/modules/crm/cases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/cases/dto.py b/backend/api/v1/modules/crm/cases/dto.py new file mode 100644 index 0000000..35fdaa1 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/dto.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class CaseResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + reference: str | None + account_id: int | None + title: str | None + stage: str + status: str + tenant_id: int + company_id: int + created_at: datetime + updated_at: datetime + + +class CaseTimelineEvent(BaseModel): + kind: str # oportunidad | solicitud | cotizacion | operacion | factura + id: int + reference: str | None = None + status: str | None = None + created_at: datetime + url: str + + +class CaseWithTimeline(CaseResponse): + timeline: list[CaseTimelineEvent] = [] diff --git a/backend/api/v1/modules/crm/cases/models.py b/backend/api/v1/modules/crm/cases/models.py new file mode 100644 index 0000000..772c430 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/models.py @@ -0,0 +1,28 @@ +from sqlalchemy import ForeignKey, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from api.v1.common.base_models import TenantScopedMixin, TimestampMixin +from core.database import Base + + +class Case(Base, TenantScopedMixin, TimestampMixin): + """Expediente: hilo maestro de un trámite (Oportunidad → Solicitud → Cotización → + Operación → Factura). Una sola referencia (``EXP…``) que agrupa toda la historia. + Nace al crear la Oportunidad y se hereda a las entidades siguientes vía ``case_id``. + """ + + __tablename__ = "cases" + __table_args__ = {"schema": "crm"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio EXP... + account_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True + ) + title: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Etapa más avanzada alcanzada: oportunidad|solicitud|cotizacion|operacion|facturacion|cerrado + stage: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'oportunidad'")) + # abierto | cerrado + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True) + created_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/backend/api/v1/modules/crm/cases/routes.py b/backend/api/v1/modules/crm/cases/routes.py new file mode 100644 index 0000000..13cad12 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/routes.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from core.database import get_core_db +from core.security import get_current_user + +from . import service +from .dto import CaseResponse, CaseWithTimeline + +router = APIRouter() + + +def _with_timeline(db, case) -> CaseWithTimeline: + data = CaseWithTimeline.model_validate(case) + data.timeline = service.build_timeline(db, case) # type: ignore[assignment] + return data + + +@router.get("/cases", response_model=list[CaseResponse]) +def list_cases( + company_id: int = Query(..., description="Company ID"), + search: str | None = Query(None), + account_id: int | None = Query(None), + stage: str | None = Query(None), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + return service.get_cases(db, current_user["tenant_id"], company_id, search, account_id, stage) + + +@router.get("/cases/by-ref/{reference}", response_model=CaseWithTimeline) +def get_case_by_ref( + reference: str, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """Expediente + historia completa por su referencia (para UI y otros sistemas).""" + case = service.get_case_by_reference(db, reference, current_user["tenant_id"], company_id) + return _with_timeline(db, case) + + +@router.get("/cases/{case_id}", response_model=CaseWithTimeline) +def get_case( + case_id: int, + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + case = service.get_case(db, case_id, current_user["tenant_id"], company_id) + return _with_timeline(db, case) diff --git a/backend/api/v1/modules/crm/cases/service.py b/backend/api/v1/modules/crm/cases/service.py new file mode 100644 index 0000000..733c629 --- /dev/null +++ b/backend/api/v1/modules/crm/cases/service.py @@ -0,0 +1,109 @@ +"""Lógica del Expediente: minteo del folio, avance de etapa y armado del timeline.""" + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from ..common.folios import next_folio +from .models import Case + +# Orden de etapas (solo se avanza, nunca retrocede) +STAGE_ORDER = ["oportunidad", "solicitud", "cotizacion", "operacion", "facturacion", "cerrado"] + + +def create_case( + db: Session, tenant_id: int, company_id: int, *, account_id: int | None = None, + title: str | None = None, stage: str = "oportunidad", user_id: str | None = None, +) -> Case: + """Mintea un expediente con folio EXP... (sin commit; lo confirma quien lo invoca).""" + case = Case( + reference=next_folio(db, tenant_id, company_id, "EXP", None, with_direction=False), + account_id=account_id, title=title, stage=stage, status="abierto", + tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id, + ) + db.add(case) + db.flush() + return case + + +def advance_stage(db: Session, case_id: int | None, stage: str) -> None: + """Avanza la etapa del expediente si la nueva es posterior a la actual.""" + if not case_id or stage not in STAGE_ORDER: + return + case = db.query(Case).filter(Case.id == case_id).first() + if not case: + return + current = case.stage if case.stage in STAGE_ORDER else "oportunidad" + if STAGE_ORDER.index(stage) > STAGE_ORDER.index(current): + case.stage = stage + + +def get_cases( + db: Session, tenant_id: int, company_id: int, search: str | None = None, + account_id: int | None = None, stage: str | None = None, +) -> list[Case]: + q = db.query(Case).filter( + Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None), + ) + if account_id is not None: + q = q.filter(Case.account_id == account_id) + if stage: + q = q.filter(Case.stage == stage) + if search: + q = q.filter(Case.reference.ilike(f"%{search}%")) + return q.order_by(Case.created_at.desc()).all() + + +def get_case(db: Session, case_id: int, tenant_id: int, company_id: int) -> Case: + obj = ( + db.query(Case) + .filter(Case.id == case_id, Case.tenant_id == tenant_id, Case.company_id == company_id, Case.deleted_at.is_(None)) + .first() + ) + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado") + return obj + + +def get_case_by_reference(db: Session, reference: str, tenant_id: int, company_id: int) -> Case: + obj = ( + db.query(Case) + .filter(Case.reference == reference, Case.tenant_id == tenant_id, Case.company_id == company_id, + Case.deleted_at.is_(None)) + .first() + ) + if not obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado") + return obj + + +def build_timeline(db: Session, case: Case) -> list[dict]: + """Devuelve la historia del expediente: todas las entidades ligadas por case_id, + en orden cronológico. Un único lookup para la UI y para otros sistemas.""" + # Import local para evitar ciclos de importación entre módulos. + from ..opportunities.models import Opportunity + from ..quotes.models import Quote + from ..service_requests.models import ServiceRequest + from api.v1.modules.fin.invoices.models import Invoice + from api.v1.modules.ops.shipments.models import Shipment + + events: list[dict] = [] + specs = [ + ("oportunidad", Opportunity, "/dashboard/crm/oportunidades"), + ("solicitud", ServiceRequest, "/dashboard/crm/solicitudes"), + ("cotizacion", Quote, "/dashboard/crm/cotizaciones"), + ("operacion", Shipment, "/dashboard/ops/embarques"), + ("factura", Invoice, "/dashboard/fin/facturas"), + ] + for kind, model, base_url in specs: + rows = db.query(model).filter(model.case_id == case.id, model.deleted_at.is_(None)).all() + for r in rows: + events.append({ + "kind": kind, + "id": r.id, + "reference": getattr(r, "reference", None), + "status": getattr(r, "status", None), + "created_at": r.created_at, + "url": f"{base_url}/{r.id}", + }) + events.sort(key=lambda e: e["created_at"]) + return events diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index 30da343..c857f03 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -867,3 +867,8 @@ GLOBAL_CATALOGS.update({ for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []): if len(_fp['code']) == 1: _fp['code'] = _fp['code'].zfill(2) + +# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`. +from .seed_locations import LOCATION_CATALOGS # noqa: E402 + +GLOBAL_CATALOGS.update(LOCATION_CATALOGS) diff --git a/backend/api/v1/modules/crm/catalogs/seed_locations.py b/backend/api/v1/modules/crm/catalogs/seed_locations.py new file mode 100644 index 0000000..759f4a7 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed_locations.py @@ -0,0 +1,79 @@ +"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA). + +Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=`). Curado a las +rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios). +Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario +para que el Cotizador encuentre ruta. +""" + +# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)]) +_LOC = [ + ("MEX", + [("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"), + ("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")], + [("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"), + ("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")], + [("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"), + ("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]), + ("USA", + [("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"), + ("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")], + [("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"), + ("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")], + [("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"), + ("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]), + ("CHN", + [("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"), + ("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")], + [("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"), + ("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")], + [("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"), + ("SZX", "Shenzhen"), ("HKG", "Hong Kong")]), + ("DEU", + [("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")], + [("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")], + [("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]), + ("ESP", + [("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")], + [("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")], + [("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]), + ("NLD", + [("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")], + [("NLRTM", "Róterdam")], + [("AMS", "Ámsterdam Schiphol")]), + ("BRA", + [("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")], + [("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")], + [("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]), + ("CAN", + [("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")], + [("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")], + [("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]), + ("JPN", + [("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")], + [("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")], + [("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]), + ("KOR", + [("KR-SEL", "Seúl"), ("KR-PUS", "Busan")], + [("KRPUS", "Busan"), ("KRINC", "Incheon")], + [("ICN", "Seúl Incheon")]), +] + + +def _build() -> dict: + ciudad, puerto, aeropuerto = [], [], [] + for iso3, cities, ports, airports in _LOC: + for code, label in cities: + ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3}) + for code, label in ports: + puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3}) + for code, label in airports: + aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3}) + return { + "ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad}, + "puerto": {"label": "Puerto", "is_system": True, "items": puerto}, + "aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto}, + } + + +LOCATION_CATALOGS = _build() diff --git a/backend/api/v1/modules/crm/common/folios.py b/backend/api/v1/modules/crm/common/folios.py index 55ce31a..19eaeb2 100644 --- a/backend/api/v1/modules/crm/common/folios.py +++ b/backend/api/v1/modules/crm/common/folios.py @@ -21,8 +21,8 @@ from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import BaseTimestampMixin, TenantScopedMixin from core.database import Base -# Entidades válidas y su letra de folio (F = factura, sin dirección impo/expo). -ENTITIES = ("O", "S", "C", "OP", "F") +# Entidades válidas y su letra de folio (F = factura, EXP = expediente; sin dirección). +ENTITIES = ("O", "S", "C", "OP", "F", "EXP") # Mapa dirección de operación → sufijo del folio. _DIRECTION_SUFFIX = {"importacion": "I", "exportacion": "E"} diff --git a/backend/api/v1/modules/crm/leads/dto.py b/backend/api/v1/modules/crm/leads/dto.py index 904dd47..6173b46 100644 --- a/backend/api/v1/modules/crm/leads/dto.py +++ b/backend/api/v1/modules/crm/leads/dto.py @@ -11,6 +11,7 @@ class LeadCreate(BaseModel): phone: str | None = Field(None, max_length=40) company_name: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) + preferred_contact_method: str | None = Field(None, max_length=20) status: str = Field("new", max_length=20) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) owner_user_id: str | None = Field(None, max_length=64) @@ -24,6 +25,7 @@ class LeadUpdate(BaseModel): phone: str | None = Field(None, max_length=40) company_name: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) + preferred_contact_method: str | None = Field(None, max_length=20) status: str | None = Field(None, max_length=20) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) owner_user_id: str | None = Field(None, max_length=64) @@ -50,6 +52,7 @@ class LeadResponse(BaseModel): phone: str | None company_name: str | None source: str | None + preferred_contact_method: str | None = None status: str estimated_value: Decimal | None owner_user_id: str | None diff --git a/backend/api/v1/modules/crm/leads/models.py b/backend/api/v1/modules/crm/leads/models.py index a1e4140..74f20a4 100644 --- a/backend/api/v1/modules/crm/leads/models.py +++ b/backend/api/v1/modules/crm/leads/models.py @@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin): company_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # Origen: web | referido | evento | llamada | email | otro source: Mapped[str | None] = mapped_column(String(60), nullable=True) + # Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|… + preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True) # Estado: new | contacted | qualified | unqualified | converted status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True) estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) diff --git a/backend/api/v1/modules/crm/opportunities/dto.py b/backend/api/v1/modules/crm/opportunities/dto.py index d37df49..0b341a4 100644 --- a/backend/api/v1/modules/crm/opportunities/dto.py +++ b/backend/api/v1/modules/crm/opportunities/dto.py @@ -69,6 +69,7 @@ class OpportunityResponse(BaseModel): notes: str | None operation_type: str | None = None reference: str | None = None + case_id: int | None = None converted_service_request_id: int | None = None tenant_id: int company_id: int diff --git a/backend/api/v1/modules/crm/opportunities/models.py b/backend/api/v1/modules/crm/opportunities/models.py index 05b5b62..427c127 100644 --- a/backend/api/v1/modules/crm/opportunities/models.py +++ b/backend/api/v1/modules/crm/opportunities/models.py @@ -43,6 +43,8 @@ class Opportunity(Base, TenantScopedMixin, TimestampMixin): # Dirección de la operación (importacion|exportacion): se hereda a Solicitud→Cotización→Embarque operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True) reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio O... + # Expediente (hilo maestro del trámite); nace aquí y se hereda hacia abajo + case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # Solicitud generada al convertir la oportunidad (back-link idempotente) converted_service_request_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("crm.service_requests.id"), nullable=True diff --git a/backend/api/v1/modules/crm/opportunities/service.py b/backend/api/v1/modules/crm/opportunities/service.py index 975b443..a75ff18 100644 --- a/backend/api/v1/modules/crm/opportunities/service.py +++ b/backend/api/v1/modules/crm/opportunities/service.py @@ -4,6 +4,7 @@ from fastapi import HTTPException, status from sqlalchemy.orm import Session from ..accounts.models import Account +from ..cases import service as cases_service from ..common.folios import next_folio from ..contacts.models import Contact from ..pipelines.models import Pipeline, PipelineStage @@ -159,6 +160,12 @@ def create_opportunity( # Folio O... auto-generado (mensual). La dirección impo/expo se hereda al ciclo. if not opportunity.reference: opportunity.reference = next_folio(db, tenant_id, company_id, "O", opportunity.operation_type) + # Expediente: nace con la oportunidad y se hereda a solicitud/cotización/operación/factura + if not opportunity.case_id: + case = cases_service.create_case( + db, tenant_id, company_id, account_id=opportunity.account_id, title=opportunity.name, stage="oportunidad", + ) + opportunity.case_id = case.id db.add(opportunity) db.commit() db.refresh(opportunity) diff --git a/backend/api/v1/modules/crm/quotes/dto.py b/backend/api/v1/modules/crm/quotes/dto.py index 0af9bd4..a55e280 100644 --- a/backend/api/v1/modules/crm/quotes/dto.py +++ b/backend/api/v1/modules/crm/quotes/dto.py @@ -85,6 +85,8 @@ class QuoteResponse(QuoteBase): model_config = ConfigDict(from_attributes=True) id: int + service_request_reference: str | None = None # folio de la solicitud referenciada + case_id: int | None = None status: str total_cost: Decimal total_sale: Decimal diff --git a/backend/api/v1/modules/crm/quotes/models.py b/backend/api/v1/modules/crm/quotes/models.py index 653a383..317e471 100644 --- a/backend/api/v1/modules/crm/quotes/models.py +++ b/backend/api/v1/modules/crm/quotes/models.py @@ -15,6 +15,7 @@ class Quote(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente service_request_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True ) diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index 5afeea2..7a0b71d 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -6,6 +6,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from ..accounts.models import Account +from ..cases import service as cases_service from ..catalogs.models import CatalogItem from ..common.folios import next_folio from ..common.pricing import air_chargeable_kg @@ -73,7 +74,18 @@ def get_quotes( query = query.filter(Quote.account_id == account_id) if search: query = query.filter(Quote.reference.ilike(f"%{search}%")) - return query.order_by(Quote.created_at.desc()).all() + quotes = query.order_by(Quote.created_at.desc()).all() + # Enriquecer con el folio de la solicitud referenciada (para verlo en la lista) + sr_ids = {q.service_request_id for q in quotes if q.service_request_id} + if sr_ids: + refs = dict( + db.query(ServiceRequest.id, ServiceRequest.reference) + .filter(ServiceRequest.id.in_(sr_ids)) + .all() + ) + for q in quotes: + q.service_request_reference = refs.get(q.service_request_id) + return quotes def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote: @@ -112,6 +124,12 @@ def create_quote( # Folio C... auto-generado (mensual), con la dirección heredada de la solicitud if not obj.reference: obj.reference = next_folio(db, tenant_id, company_id, "C", _sr_direction(db, obj.service_request_id)) + # Expediente heredado de la solicitud + if obj.service_request_id and not obj.case_id: + sr = db.query(ServiceRequest).filter(ServiceRequest.id == obj.service_request_id).first() + if sr: + obj.case_id = sr.case_id + cases_service.advance_stage(db, obj.case_id, "cotizacion") db.add(obj) db.commit() db.refresh(obj) @@ -173,6 +191,7 @@ def create_quotes_from_service_request( notes=sr.client_notes or sr.notes, owner_user_id=sr.owner_user_id, reference=next_folio(db, tenant_id, company_id, "C", sr.operation_type), + case_id=sr.case_id, tenant_id=tenant_id, company_id=company_id, created_by=user_id, @@ -214,6 +233,7 @@ def create_quotes_from_service_request( _recompute_totals(db, quote) created.append(quote) + cases_service.advance_stage(db, sr.case_id, "cotizacion") db.commit() for quote in created: db.refresh(quote) diff --git a/backend/api/v1/modules/crm/router.py b/backend/api/v1/modules/crm/router.py index c21a287..09353dc 100644 --- a/backend/api/v1/modules/crm/router.py +++ b/backend/api/v1/modules/crm/router.py @@ -13,6 +13,7 @@ from . import permissions # noqa: F401 (side-effect: registra permisos del CRM from .accounts.routes import router as accounts_router from .activities.routes import router as activities_router from .addresses.routes import router as addresses_router +from .cases.routes import router as cases_router from .catalogs.routes import router as catalogs_router from .contacts.routes import router as contacts_router from .documents.routes import router as documents_router @@ -43,6 +44,7 @@ router.include_router(leads_router) router.include_router(pipelines_router) router.include_router(opportunities_router) router.include_router(activities_router) +router.include_router(cases_router) router.include_router(metrics_router) router.include_router(catalogs_router) router.include_router(uploads_router) diff --git a/backend/api/v1/modules/crm/service_requests/dto.py b/backend/api/v1/modules/crm/service_requests/dto.py index a45ddde..9e51a9b 100644 --- a/backend/api/v1/modules/crm/service_requests/dto.py +++ b/backend/api/v1/modules/crm/service_requests/dto.py @@ -162,6 +162,7 @@ class ServiceRequestResponse(ServiceRequestBase): model_config = ConfigDict(from_attributes=True) id: int + case_id: int | None = None first_contact_at: datetime | None = None first_contact_notes: str | None = None tenant_id: int diff --git a/backend/api/v1/modules/crm/service_requests/models.py b/backend/api/v1/modules/crm/service_requests/models.py index 5fbd64e..36d0bf7 100644 --- a/backend/api/v1/modules/crm/service_requests/models.py +++ b/backend/api/v1/modules/crm/service_requests/models.py @@ -19,6 +19,7 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio + case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente account_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True ) diff --git a/backend/api/v1/modules/crm/service_requests/service.py b/backend/api/v1/modules/crm/service_requests/service.py index 42b096a..ca7bfa2 100644 --- a/backend/api/v1/modules/crm/service_requests/service.py +++ b/backend/api/v1/modules/crm/service_requests/service.py @@ -4,6 +4,7 @@ from fastapi import HTTPException, status from sqlalchemy.orm import Session from ..accounts.models import Account +from ..cases import service as cases_service from ..catalogs.data import INCOTERM_CODES from ..common.folios import next_folio from ..contacts.models import Contact @@ -110,6 +111,12 @@ def create_service_request( # Folio S... auto-generado (mensual) si no viene uno explícito if not obj.reference: obj.reference = next_folio(db, tenant_id, company_id, "S", obj.operation_type) + # Expediente: normalmente nace en la oportunidad; si la solicitud es directa, se mintea aquí + if not obj.case_id: + case = cases_service.create_case( + db, tenant_id, company_id, account_id=obj.account_id, title=obj.reference, stage="solicitud", user_id=user_id, + ) + obj.case_id = case.id db.add(obj) db.commit() db.refresh(obj) @@ -200,6 +207,7 @@ def create_from_opportunity( notes=payload.notes, owner_user_id=opp.owner_user_id, reference=next_folio(db, tenant_id, company_id, "S", operation_type), + case_id=opp.case_id, tenant_id=tenant_id, company_id=company_id, created_by=user_id, @@ -207,6 +215,13 @@ def create_from_opportunity( ) db.add(obj) db.flush() + # Expediente heredado de la oportunidad (fallback si la oportunidad es antigua sin expediente) + if not obj.case_id: + obj.case_id = cases_service.create_case( + db, tenant_id, company_id, account_id=opp.account_id, title=obj.reference, stage="solicitud", user_id=user_id, + ).id + opp.case_id = obj.case_id + cases_service.advance_stage(db, obj.case_id, "solicitud") # Back-link para cerrar el ciclo Oportunidad→Solicitud (y garantizar idempotencia) opp.converted_service_request_id = obj.id db.commit() diff --git a/backend/api/v1/modules/crm/uploads/routes.py b/backend/api/v1/modules/crm/uploads/routes.py index 63757dd..7968a27 100644 --- a/backend/api/v1/modules/crm/uploads/routes.py +++ b/backend/api/v1/modules/crm/uploads/routes.py @@ -7,10 +7,10 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran). import re import uuid -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from core.security import get_current_user -from core.storage_s3 import presigned_get_url, put_object_bytes +from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes router = APIRouter() @@ -61,3 +61,29 @@ def get_upload_url( if not key.startswith(prefix): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") return {"url": presigned_get_url(key)} + + +@router.get("/uploads/download") +def download_file( + key: str = Query(..., description="Object key del archivo en el almacén"), + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), +): + """Transmite el archivo por el backend (sin exponer MinIO al navegador). + + Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``. + """ + tenant_id = current_user["tenant_id"] + prefix = f"tenants/{tenant_id}/companies/{company_id}/" + if not key.startswith(prefix): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") + try: + data = get_object_bytes(key) + except Exception: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado") + filename = key.rsplit("/", 1)[-1] + return Response( + content=data, + media_type="application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) diff --git a/backend/api/v1/modules/fin/invoices/dto.py b/backend/api/v1/modules/fin/invoices/dto.py index 5987717..c529684 100644 --- a/backend/api/v1/modules/fin/invoices/dto.py +++ b/backend/api/v1/modules/fin/invoices/dto.py @@ -100,6 +100,7 @@ class InvoiceResponse(InvoiceBase): model_config = ConfigDict(from_attributes=True) id: int + case_id: int | None = None status: str subtotal: Decimal tax_amount: Decimal diff --git a/backend/api/v1/modules/fin/invoices/models.py b/backend/api/v1/modules/fin/invoices/models.py index 1ebdac4..dd53a6f 100644 --- a/backend/api/v1/modules/fin/invoices/models.py +++ b/backend/api/v1/modules/fin/invoices/models.py @@ -15,6 +15,7 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio + case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente shipment_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("ops.shipments.id"), nullable=True, index=True ) diff --git a/backend/api/v1/modules/fin/invoices/service.py b/backend/api/v1/modules/fin/invoices/service.py index 907653a..82722c8 100644 --- a/backend/api/v1/modules/fin/invoices/service.py +++ b/backend/api/v1/modules/fin/invoices/service.py @@ -6,6 +6,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from api.v1.modules.crm.accounts.models import Account +from api.v1.modules.crm.cases import service as cases_service from api.v1.modules.crm.common.folios import next_folio from api.v1.modules.crm.quotes.models import Quote, QuoteItem from api.v1.modules.ops.shipments.models import Shipment @@ -99,6 +100,12 @@ def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=No # Folio F... auto-generado (mensual) si no viene uno explícito if not obj.reference: obj.reference = next_folio(db, tenant_id, company_id, "F", None, with_direction=False) + # Expediente heredado del embarque (si la factura se genera de uno) + if obj.shipment_id and not obj.case_id: + sh = db.query(Shipment).filter(Shipment.id == obj.shipment_id).first() + if sh: + obj.case_id = sh.case_id + cases_service.advance_stage(db, obj.case_id, "facturacion") db.add(obj) db.flush() _recompute(db, obj) @@ -285,6 +292,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None) invoice = Invoice( reference=shipment.reference, + case_id=shipment.case_id, shipment_id=shipment.id, quote_id=shipment.quote_id, account_id=shipment.account_id, @@ -298,6 +306,7 @@ def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None) ) db.add(invoice) db.flush() + cases_service.advance_stage(db, shipment.case_id, "facturacion") if quote: q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all() diff --git a/backend/api/v1/modules/ops/shipments/dto.py b/backend/api/v1/modules/ops/shipments/dto.py index e9b15d9..af889a0 100644 --- a/backend/api/v1/modules/ops/shipments/dto.py +++ b/backend/api/v1/modules/ops/shipments/dto.py @@ -82,6 +82,7 @@ class ShipmentResponse(ShipmentBase): model_config = ConfigDict(from_attributes=True) id: int + case_id: int | None = None closed_at: datetime | None = None closed_by: str | None = None created_by: str | None = None diff --git a/backend/api/v1/modules/ops/shipments/models.py b/backend/api/v1/modules/ops/shipments/models.py index d65c542..6d04170 100644 --- a/backend/api/v1/modules/ops/shipments/models.py +++ b/backend/api/v1/modules/ops/shipments/models.py @@ -15,6 +15,7 @@ class Shipment(Base, TenantScopedMixin, TimestampMixin): id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque + case_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("crm.cases.id"), nullable=True, index=True) # expediente quote_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True ) diff --git a/backend/api/v1/modules/ops/shipments/service.py b/backend/api/v1/modules/ops/shipments/service.py index 2372cd1..adf57fb 100644 --- a/backend/api/v1/modules/ops/shipments/service.py +++ b/backend/api/v1/modules/ops/shipments/service.py @@ -5,6 +5,7 @@ from sqlalchemy import func from sqlalchemy.orm import Session from api.v1.modules.crm.accounts.models import Account +from api.v1.modules.crm.cases import service as cases_service from api.v1.modules.crm.common.folios import next_folio from api.v1.modules.crm.quotes.models import Quote from api.v1.modules.crm.service_requests.models import ServiceRequest @@ -218,6 +219,7 @@ def create_shipment_from_quote( shipment = Shipment( reference=next_folio(db, tenant_id, company_id, "OP", resolved), + case_id=quote.case_id, quote_id=quote.id, service_request_id=quote.service_request_id, account_id=quote.account_id, @@ -238,6 +240,7 @@ def create_shipment_from_quote( db.add(shipment) if sr: sr.status = "liberada" + cases_service.advance_stage(db, quote.case_id, "operacion") db.flush() # Siembra automática de hitos si ya se conoce la dirección de la operación for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index dd9bbf5..cf070ca 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,6 +28,7 @@ from core.database import Base # noqa: E402 import api.v1.modules.crm.accounts.models # noqa: E402,F401 import api.v1.modules.crm.activities.models # noqa: E402,F401 import api.v1.modules.crm.addresses.models # noqa: E402,F401 +import api.v1.modules.crm.cases.models # noqa: E402,F401 import api.v1.modules.crm.catalogs.models # noqa: E402,F401 import api.v1.modules.crm.common.folios # noqa: E402,F401 import api.v1.modules.crm.contacts.models # noqa: E402,F401 diff --git a/backend/tests/test_cases.py b/backend/tests/test_cases.py new file mode 100644 index 0000000..9d6e522 --- /dev/null +++ b/backend/tests/test_cases.py @@ -0,0 +1,48 @@ +"""Pruebas del Expediente (crm.cases): minteo, propagación y timeline.""" + +from api.v1.modules.crm.cases import service as cases_service +from api.v1.modules.crm.opportunities import service as opp_service +from api.v1.modules.crm.opportunities.dto import OpportunityCreate +from api.v1.modules.crm.quotes import service as q_service +from api.v1.modules.crm.service_requests import service as sr_service +from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate, ServiceRequestFromOpportunityInput + +T, C = 1, 1 + + +def test_opportunity_mints_expediente(db): + opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="exportacion"), T, C) + assert opp.case_id is not None + case = cases_service.get_case(db, opp.case_id, T, C) + assert (case.reference or "").startswith("EXP") + assert case.stage == "oportunidad" + + +def test_case_propagates_and_advances(db): + opp = opp_service.create_opportunity(db, OpportunityCreate(name="Negocio", operation_type="importacion"), T, C) + sr = sr_service.create_from_opportunity(db, opp.id, ServiceRequestFromOpportunityInput(), T, C) + assert sr.case_id == opp.case_id + assert cases_service.get_case(db, opp.case_id, T, C).stage == "solicitud" + + quotes = q_service.create_quotes_from_service_request(db, sr.id, T, C) + assert quotes[0].case_id == opp.case_id + case = cases_service.get_case(db, opp.case_id, T, C) + assert case.stage == "cotizacion" + + # El timeline reúne toda la historia ligada al expediente + kinds = {e["kind"] for e in cases_service.build_timeline(db, case)} + assert {"oportunidad", "solicitud", "cotizacion"} <= kinds + + +def test_direct_service_request_mints_expediente(db): + # Solicitud directa (sin oportunidad) también obtiene expediente (fallback) + sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C) + assert sr.case_id is not None + assert cases_service.get_case(db, sr.case_id, T, C).stage == "solicitud" + + +def test_advance_stage_never_regresses(db): + opp = opp_service.create_opportunity(db, OpportunityCreate(name="N", operation_type="exportacion"), T, C) + cases_service.advance_stage(db, opp.case_id, "facturacion") + cases_service.advance_stage(db, opp.case_id, "solicitud") # no debe retroceder + assert cases_service.get_case(db, opp.case_id, T, C).stage == "facturacion" diff --git a/frontend/src/lib/api/crm/cases.ts b/frontend/src/lib/api/crm/cases.ts new file mode 100644 index 0000000..fa136a3 --- /dev/null +++ b/frontend/src/lib/api/crm/cases.ts @@ -0,0 +1,50 @@ +/** + * Cliente API — Expedientes (referencia única de trazabilidad del trámite). + */ +import { api } from '$lib/api'; + +export interface Case { + id: number; + reference: string | null; + account_id: number | null; + title: string | null; + stage: string; + status: string; + tenant_id: number; + company_id: number; + created_at: string; + updated_at: string; +} + +export interface CaseTimelineEvent { + kind: string; // oportunidad | solicitud | cotizacion | operacion | factura + id: number; + reference: string | null; + status: string | null; + created_at: string; + url: string; +} + +export interface CaseWithTimeline extends Case { + timeline: CaseTimelineEvent[]; +} + +function qp(companyId: number, extra?: Record) { + 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(p: Promise<{ data?: T; error?: string }>): Promise { + const res = await p; + if (res.error) throw new Error(res.error); + return res.data as T; +} + +export const casesAPI = { + list: (companyId: number, params?: { search?: string; account_id?: number; stage?: string }) => + unwrap(api.get(`/v1/crm/cases?${qp(companyId, params)}`)), + get: (id: number, companyId: number) => + unwrap(api.get(`/v1/crm/cases/${id}?${qp(companyId)}`)), + byRef: (reference: string, companyId: number) => + unwrap(api.get(`/v1/crm/cases/by-ref/${encodeURIComponent(reference)}?${qp(companyId)}`)) +}; diff --git a/frontend/src/lib/api/crm/commercial.ts b/frontend/src/lib/api/crm/commercial.ts index 3afe080..8365ccc 100644 --- a/frontend/src/lib/api/crm/commercial.ts +++ b/frontend/src/lib/api/crm/commercial.ts @@ -10,6 +10,7 @@ export type QuoteStatus = 'borrador' | 'enviada' | 'aceptada' | 'rechazada'; export interface ServiceRequest { id: number; reference: string | null; + case_id: number | null; account_id: number | null; contact_id: number | null; opportunity_id: number | null; @@ -106,6 +107,8 @@ export interface Quote { id: number; reference: string | null; service_request_id: number | null; + service_request_reference: string | null; + case_id: number | null; account_id: number | null; currency: string; load_type: string | null; diff --git a/frontend/src/lib/api/crm/index.ts b/frontend/src/lib/api/crm/index.ts index a2e40f9..8441fa9 100644 --- a/frontend/src/lib/api/crm/index.ts +++ b/frontend/src/lib/api/crm/index.ts @@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities'; export { activitiesAPI } from './activities'; export { metricsAPI } from './metrics'; export * from './commercial'; +export * from './cases'; diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index ca0e2a1..f20f47a 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -192,6 +192,7 @@ export interface Lead { phone: string | null; company_name: string | null; source: string | null; + preferred_contact_method: string | null; status: LeadStatus; estimated_value: number | null; owner_user_id: string | null; diff --git a/frontend/src/lib/api/uploads.ts b/frontend/src/lib/api/uploads.ts index 31dcf25..9a399fc 100644 --- a/frontend/src/lib/api/uploads.ts +++ b/frontend/src/lib/api/uploads.ts @@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise { + return (api as any).getBlob( + `/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}` + ) as Promise; +} diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index fb0866e..3900dd2 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -11,7 +11,7 @@ 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 { uploadFile, downloadBlob } from '$lib/api/uploads'; import { toast } from 'svelte-sonner'; onMount(() => { @@ -62,9 +62,17 @@ async function openDoc(d: Document) { if (!companyId) return; try { - const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url; - if (url) window.open(url, '_blank', 'noopener'); - else toast.error('El documento no tiene archivo'); + if (d.file_key) { + // Descarga por el backend (evita exponer MinIO / host interno) + const blob = await downloadBlob(d.file_key, companyId); + const url = URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } else if (d.file_url) { + window.open(d.file_url, '_blank', 'noopener'); + } else { + toast.error('El documento no tiene archivo'); + } } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo'); } diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte index f5177ed..41c24c8 100644 --- a/frontend/src/lib/components/crm/ServiceRequestFields.svelte +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -21,25 +21,85 @@ const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring'; - // FCL/LCL condicionales; "AMBAS" muestra ambas secciones; "AEREO" muestra la sección aérea - const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS'); - const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS'); - const isAir = $derived(form.load_type === 'AEREO'); - - // Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol) - const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1); - const airVolumetric = $derived( - Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0 - ? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000 - : 0 + // Modalidad de carga según el tipo de transporte (solo se habilita lo que corresponde) + const MODALIDAD_BY_TRANSPORT: Record = { + maritimo: ['FCL', 'LCL', 'AMBAS'], + aereo: ['AEREO'], + terrestre: ['FTL', 'LTL'] + // ferroviario / multimodal: sin modalidad + }; + const modalidadOptions = $derived( + LOAD_TYPES.filter((l) => (MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []).includes(l.value)) ); - const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); + const showModalidad = $derived(modalidadOptions.length > 0); + // Reglas: al cambiar el transporte, la modalidad inválida se limpia; si solo hay una (aéreo), se autoselecciona + $effect(() => { + const allowed = MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []; + if (allowed.length === 0) { + if (form.load_type) form.load_type = undefined; + return; + } + if (form.load_type && !allowed.includes(form.load_type)) form.load_type = undefined; + if (!form.load_type && allowed.length === 1) form.load_type = allowed[0]; + }); // La modalidad aérea fija el medio de transporte en "aéreo" $effect(() => { if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo'; }); + const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS'); + const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS'); + const isAir = $derived(form.load_type === 'AEREO'); + + // Conversión de dimensiones a cm según la unidad de medida (para volumen m³ y P/Vol) + const UNIT_TO_CM: Record = { cm: 1, m: 100, in: 2.54, ft: 30.48 }; + const unitCm = $derived(UNIT_TO_CM[form.measurement_unit ?? 'cm'] ?? 1); + const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1); + const dimL = $derived((Number(form.length_cm) || 0) * unitCm); + const dimW = $derived((Number(form.width_cm) || 0) * unitCm); + const dimH = $derived((Number(form.height_cm) || 0) * unitCm); + const hasDims = $derived(dimL > 0 && dimW > 0 && dimH > 0); + // Volumen SIEMPRE en m³ (cm³ / 1,000,000) + const volumeM3 = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 1_000_000 : 0); + // P/Vol aéreo (kg) = (L×A×H cm × bultos) / 6000; a cobrar = max(bruto, P/Vol) + const airVolumetric = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 6000 : 0); + const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); + + // Autocompletar el volumen en m³ a partir de las dimensiones/unidad + $effect(() => { + if (hasDims) form.volume = Math.round(volumeM3 * 1000) / 1000; + }); + + // Ciudad y puerto/aeropuerto dependen del país (catálogos dependientes, como estado←país) + $effect(() => { + if (form.origin_country) { + void crmCatalogs.ensure('ciudad', form.origin_country); + void crmCatalogs.ensure('puerto', form.origin_country); + void crmCatalogs.ensure('aeropuerto', form.origin_country); + } + }); + $effect(() => { + if (form.destination_country) { + void crmCatalogs.ensure('ciudad', form.destination_country); + void crmCatalogs.ensure('puerto', form.destination_country); + void crmCatalogs.ensure('aeropuerto', form.destination_country); + } + }); + // Campo "Puerto/Aeropuerto": une puertos + aeropuertos del país + function portOptions(country: string | null | undefined) { + return [ + ...crmCatalogs.options('puerto', country ?? undefined), + ...crmCatalogs.options('aeropuerto', country ?? undefined) + ]; + } + + // Agente en destino: solo proveedores clasificados como corresponsal/aduanal (fallback: todos) + const destinationAgents = $derived( + suppliers.filter((s) => (s.classifications ?? []).some((c) => c === 'agente_corresponsal' || c === 'agente_aduanal')) + ); + const agentList = $derived(destinationAgents.length ? destinationAgents : suppliers); + // Contactos del cliente seleccionado (o todos si no hay cliente) const clientContacts = $derived( form.account_id ? contacts.filter((c) => c.account_id === form.account_id) : contacts @@ -91,13 +151,19 @@ - + {#if showModalidad} + + {/if}

Origen

- - {#if crmCatalogs.options('puerto').length} - + {#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.origin_country).length} + {:else} {/if} @@ -105,9 +171,13 @@

Destino

- - {#if crmCatalogs.options('puerto').length} - + {#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.destination_country).length} + {:else} {/if} @@ -115,14 +185,14 @@ - + {:else if tab === 'mercancia'}
- +
@@ -172,9 +242,9 @@

P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.

Cantidad de bultos{airQty}
-
Peso volumétrico (P/Vol){airVolumetric.toFixed(2)}
+
Peso volumétrico (P/Vol){airVolumetric.toFixed(2)} kg
Peso bruto{(Number(form.weight) || 0).toFixed(2)} kg
-
Peso a cobrar{airChargeable.toFixed(2)} kg
+
Peso a cobrar (P/Vol){airChargeable.toFixed(2)} kg
{/if} diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index b267a95..59f69d9 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -45,6 +45,7 @@ export function getNavMain(): NavMainItem[] { // Orden por flujo comercial: captación → embudo → solicitud → cotización → apoyo items: [ { title: 'Panel', url: '/dashboard/crm' }, + { title: 'Expedientes', url: '/dashboard/crm/expedientes' }, { title: 'Clientes / Prospectos', url: '/dashboard/crm/cuentas' }, { title: 'Contactos', url: '/dashboard/crm/contactos' }, { title: 'Prospectos (embudo)', url: '/dashboard/crm/prospectos' }, diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte index cc5317a..09be57f 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/+page.svelte @@ -93,6 +93,7 @@ Folio + Solicitud Estatus Total venta Margen @@ -103,6 +104,7 @@ {#each filtered as q (q.id)} {q.reference ?? `#${q.id}`} + {#if q.service_request_id}{q.service_request_reference ?? `#${q.service_request_id}`}{:else}{/if} {labelOf(QUOTE_STATUS, q.status)} {formatMoney(q.total_sale, q.currency)} {formatMoney(q.margin, q.currency)} diff --git a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte index 9b227b8..d252b41 100644 --- a/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizaciones/[id]/+page.svelte @@ -1,5 +1,5 @@ + +
+
+

Expedientes

+

Referencia única que hila todo el trámite (oportunidad → solicitud → cotización → operación → factura).

+
+ + + +
+ + +
+
+ + {#if loading} +

Cargando…

+ {:else if filtered.length === 0} +

Sin expedientes. Se crean automáticamente al generar una oportunidad.

+ {:else} +
+ + + + Expediente + Cliente + Etapa + Estatus + Creado + + + + + {#each filtered as c (c.id)} + + {c.reference ?? `#${c.id}`} + {accountName(c.account_id)} + {STAGE_LABEL[c.stage] ?? c.stage} + {c.status} + {formatDate(c.created_at)} + + + {/each} + + +
+ {/if} +
+
+
diff --git a/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte new file mode 100644 index 0000000..f520370 --- /dev/null +++ b/frontend/src/routes/dashboard/crm/expedientes/[id]/+page.svelte @@ -0,0 +1,81 @@ + + +
+ + + {#if loading && !data} +

Cargando…

+ {:else if data} +
+

{data.reference ?? `Expediente #${data.id}`}

+

Etapa: {STAGE_LABEL[data.stage] ?? data.stage} · {data.status}{#if data.title} · {data.title}{/if}

+
+ + + Historia del trámite + Todos los documentos ligados a este expediente, en orden cronológico. + + + {#if data.timeline.length === 0} +

Sin movimientos aún.

+ {:else} +
    + {#each data.timeline as ev (ev.kind + '-' + ev.id)} + {@const K = KIND[ev.kind] ?? { label: ev.kind, icon: FileText }} +
  1. + + + +
    + {K.label} + {ev.reference ?? `#${ev.id}`} + {#if ev.status}{ev.status}{/if} + {formatDate(ev.created_at)} +
    +
  2. + {/each} +
+ {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte index 99de7a1..6c282fe 100644 --- a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte +++ b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte @@ -4,10 +4,14 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; + import { onMount } from 'svelte'; import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm'; import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; + onMount(() => void crmCatalogs.ensure('medio_contacto')); + let items = $state([]); let loading = $state(false); let search = $state(''); @@ -233,6 +237,13 @@ {#each LEAD_SOURCES as s (s.value)}{/each} +
@@ -89,6 +99,7 @@ Folio + Cliente Operación Medio Ruta @@ -100,6 +111,7 @@ {#each filtered as r (r.id)} {r.reference ?? `#${r.id}`} + {accountName(r.account_id)} {labelOf(OPERATION_TYPES, r.operation_type)} {labelOf(TRANSPORT_MODES, r.transport_mode)} {[r.origin, r.destination].filter(Boolean).join(' → ') || '—'} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 49befff..4ed04cc 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -160,6 +160,7 @@

{sr.reference ?? `Solicitud #${sr.id}`}

{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}

+ {#if sr.case_id}📁 Expediente{/if}
{#if sr.status === 'nueva' || sr.status === 'contacto'}{/if} @@ -186,6 +187,7 @@ +
diff --git a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte index f5187ea..1c4be37 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte @@ -9,7 +9,7 @@ import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte'; import { toast } from 'svelte-sonner'; - let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} }); + let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {}, request_date: new Date().toISOString().slice(0, 10) }); let accounts = $state([]); let suppliers = $state([]); let contacts = $state([]);