feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)

- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-07 07:58:51 -06:00
parent b47dc542f2
commit afe659e56a
33 changed files with 637 additions and 3 deletions

View File

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

View File

@@ -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] = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,6 +86,7 @@ class QuoteResponse(QuoteBase):
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

View File

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

View File

@@ -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
@@ -123,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)
@@ -184,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,
@@ -225,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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 "", [])):

View File

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

View File

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

View File

@@ -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<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 casesAPI = {
list: (companyId: number, params?: { search?: string; account_id?: number; stage?: string }) =>
unwrap<Case[]>(api.get(`/v1/crm/cases?${qp(companyId, params)}`)),
get: (id: number, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/${id}?${qp(companyId)}`)),
byRef: (reference: string, companyId: number) =>
unwrap<CaseWithTimeline>(api.get(`/v1/crm/cases/by-ref/${encodeURIComponent(reference)}?${qp(companyId)}`))
};

View File

@@ -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;
@@ -107,6 +108,7 @@ export interface Quote {
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;

View File

@@ -13,3 +13,4 @@ export { opportunitiesAPI } from './opportunities';
export { activitiesAPI } from './activities';
export { metricsAPI } from './metrics';
export * from './commercial';
export * from './cases';

View File

@@ -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' },

View File

@@ -220,8 +220,9 @@
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><Receipt class="h-6 w-6" /> {quote.reference ?? `Cotización #${quote.id}`}</h1>
<p class="mt-1 text-sm">
<p class="mt-1 flex items-center gap-2 text-sm">
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusClass[quote.status] ?? ''}">{labelOf(QUOTE_STATUS, quote.status)}</span>
{#if quote.case_id}<a class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${quote.case_id}`}>📁 Expediente</a>{/if}
</p>
</div>
<div class="flex flex-wrap gap-2">

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { FolderKanban, Search, ChevronRight } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import * as Table from '$lib/components/ui/table';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { casesAPI, accountsAPI, type Case, type Account } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
let items = $state<Case[]>([]);
let accounts = $state<Account[]>([]);
let loading = $state(false);
let search = $state('');
const companyId = $derived(companyStore.activeCompany?.id ?? null);
function accountName(id: number | null): string {
return accounts.find((a) => a.id === id)?.name ?? '—';
}
const filtered = $derived(
search.trim()
? items.filter((c) => `${c.reference ?? ''} ${c.title ?? ''} ${accountName(c.account_id)}`.toLowerCase().includes(search.trim().toLowerCase()))
: items
);
$effect(() => {
const cid = companyId;
if (!cid) return;
void load(cid);
});
async function load(cid: number) {
loading = true;
try {
[items, accounts] = await Promise.all([casesAPI.list(cid), accountsAPI.list(cid)]);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los expedientes');
} finally {
loading = false;
}
}
const inputCls = 'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> Expedientes</h1>
<p class="mt-1 text-sm text-muted-foreground">Referencia única que hila todo el trámite (oportunidad → solicitud → cotización → operación → factura).</p>
</div>
<Card.Root>
<Card.Header>
<div class="relative max-w-sm">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input class="w-full py-2 pl-8 pr-3 {inputCls}" placeholder="Buscar por folio o cliente…" bind:value={search} />
</div>
</Card.Header>
<Card.Content>
{#if loading}
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
{:else if filtered.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">Sin expedientes. Se crean automáticamente al generar una oportunidad.</p>
{:else}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Expediente</Table.Head>
<Table.Head>Cliente</Table.Head>
<Table.Head>Etapa</Table.Head>
<Table.Head>Estatus</Table.Head>
<Table.Head>Creado</Table.Head>
<Table.Head></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as c (c.id)}
<Table.Row>
<Table.Cell class="font-mono font-medium"><a class="hover:underline" href={`/dashboard/crm/expedientes/${c.id}`}>{c.reference ?? `#${c.id}`}</a></Table.Cell>
<Table.Cell>{accountName(c.account_id)}</Table.Cell>
<Table.Cell>{STAGE_LABEL[c.stage] ?? c.stage}</Table.Cell>
<Table.Cell>{c.status}</Table.Cell>
<Table.Cell>{formatDate(c.created_at)}</Table.Cell>
<Table.Cell class="text-right"><Button variant="ghost" size="sm" href={`/dashboard/crm/expedientes/${c.id}`} aria-label="Abrir"><ChevronRight class="h-4 w-4" /></Button></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { ArrowLeft, FolderKanban, Target, FileText, Receipt, Ship, DollarSign } from '@lucide/svelte';
import { page } from '$app/state';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { companyStore } from '$lib/stores/company.svelte';
import { casesAPI, type CaseWithTimeline } from '$lib/api/crm';
import { formatDate } from '$lib/components/crm/format';
import { toast } from 'svelte-sonner';
const caseId = $derived(Number(page.params.id));
const companyId = $derived(companyStore.activeCompany?.id ?? null);
let data = $state<CaseWithTimeline | null>(null);
let loading = $state(false);
const STAGE_LABEL: Record<string, string> = {
oportunidad: 'Oportunidad', solicitud: 'Solicitud', cotizacion: 'Cotización',
operacion: 'Operación', facturacion: 'Facturación', cerrado: 'Cerrado'
};
const KIND: Record<string, { label: string; icon: any }> = {
oportunidad: { label: 'Oportunidad', icon: Target },
solicitud: { label: 'Solicitud', icon: FileText },
cotizacion: { label: 'Cotización', icon: Receipt },
operacion: { label: 'Operación / Embarque', icon: Ship },
factura: { label: 'Factura', icon: DollarSign }
};
$effect(() => {
const cid = companyId;
const id = caseId;
if (!cid || !id) return;
void (async () => {
loading = true;
try { data = await casesAPI.get(id, cid); }
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el expediente'); }
finally { loading = false; }
})();
});
</script>
<div class="space-y-6">
<Button variant="ghost" size="sm" href="/dashboard/crm/expedientes"><ArrowLeft class="mr-1 h-4 w-4" /> Expedientes</Button>
{#if loading && !data}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if data}
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FolderKanban class="h-6 w-6" /> <span class="font-mono">{data.reference ?? `Expediente #${data.id}`}</span></h1>
<p class="mt-1 text-sm text-muted-foreground">Etapa: <b>{STAGE_LABEL[data.stage] ?? data.stage}</b> · {data.status}{#if data.title} · {data.title}{/if}</p>
</div>
<Card.Root>
<Card.Header><Card.Title class="text-base">Historia del trámite</Card.Title>
<Card.Description>Todos los documentos ligados a este expediente, en orden cronológico.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.timeline.length === 0}
<p class="text-sm text-muted-foreground">Sin movimientos aún.</p>
{:else}
<ol class="relative ml-3 border-l pl-6">
{#each data.timeline as ev (ev.kind + '-' + ev.id)}
{@const K = KIND[ev.kind] ?? { label: ev.kind, icon: FileText }}
<li class="mb-5">
<span class="absolute -left-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background">
<K.icon class="h-3.5 w-3.5" />
</span>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs uppercase text-muted-foreground">{K.label}</span>
<a class="font-mono text-sm font-medium hover:underline" href={ev.url}>{ev.reference ?? `#${ev.id}`}</a>
{#if ev.status}<span class="rounded-full bg-muted px-2 py-0.5 text-[10px]">{ev.status}</span>{/if}
<span class="text-xs text-muted-foreground">{formatDate(ev.created_at)}</span>
</div>
</li>
{/each}
</ol>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -160,6 +160,7 @@
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight"><FileText class="h-6 w-6" /> {sr.reference ?? `Solicitud #${sr.id}`}</h1>
<p class="mt-1 text-sm text-muted-foreground">{labelOf(OPERATION_TYPES, sr.operation_type)} · {labelOf(SR_STATUS, sr.status)}</p>
{#if sr.case_id}<a class="mt-1 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 font-mono text-[11px] hover:underline" href={`/dashboard/crm/expedientes/${sr.case_id}`}>📁 Expediente</a>{/if}
</div>
<div class="flex flex-wrap gap-2">
{#if sr.status === 'nueva' || sr.status === 'contacto'}<Button size="sm" variant="outline" onclick={registerContact} disabled={busy}>Registrar contacto</Button>{/if}