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