Primera mitad del pegamento del carril (fase 7). El reflejo en EFC se pide en
create_case, en el instante en que se mintea el folio, porque el folio es la llave
con la que las dos mitades se reconocen: EFC no recibe ids del CRM como handle.
- crm.cases nace con efc_storage_token = CRM-{company}-{folio}, fijado al nacer y
nunca reescrito: es la carpeta de MinIO del lado de EFC, y que sea inmutable es
lo que permite completar el provisional con la data aduanera real sin mover un
solo archivo.
- replicate_expediente_best_effort corre en la MISMA transaccion que el
expediente. Con EFC_API_URL vacia es no-op; si el encolado o el despacho fallan
no se propaga el error y el barrido del beat recoge lo pendiente. Un sistema de
terceros caido no puede romper un alta.
- El import del carril es diferido para no acoplar el arranque del modulo del
expediente, que es de otra rama, a la integracion.
- Se registra el tablero de ops del carril (outbox, metricas, reintento manual)
en el router del CRM.
test_el_formato_del_folio_es_el_del_contrato se re-apunta a crm/common/folios.py y
queda VERDE: afirma contra la implementacion real que el folio del CRM tiene la forma
que EFC espera, que era el riesgo de haber rebasado sobre otro expediente.
Verificado en la base: create_case produce EXP2026-08-002 con storage_token
CRM-2-EXP2026-08-002 y link_state PENDING, 0 filas encoladas por carril apagado, y la
transaccion reversada NO deja hueco en el contador -- el with_for_update de
crm/common/folios.py revierte limpio.
PENDIENTE de la fase 7: documentos (subida de un paso, proxy de descarga, listado y
desvinculacion) y los 8 archivos del frontend. Por eso siguen rojas
test_efc_outbox, test_gateway_rutas, test_efc_entrega_documento, test_uploads_alcance
y dos de test_contrato_efc.
Ref: T2026-08-046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
131 lines
5.6 KiB
Python
131 lines
5.6 KiB
Python
"""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()
|
|
|
|
# ── Carril hacia EFC (T2026-08-046) ──────────────────────────────────────────────────
|
|
# EFC es la fuente única de los documentos del expediente. El reflejo allá —un pedimento
|
|
# provisional— se pide AQUÍ, en el instante en que nace el folio, porque el folio es
|
|
# precisamente la llave con la que las dos mitades se reconocen.
|
|
#
|
|
# El token de almacenamiento se fija al nacer y NO cambia nunca: es la carpeta de MinIO
|
|
# del lado de EFC. Que sea inmutable es lo que permite completar el provisional con la
|
|
# data aduanera real sin mover un solo archivo.
|
|
#
|
|
# Todo es best-effort y va en la MISMA transacción que el expediente:
|
|
# - con ``EFC_API_URL`` vacía es un no-op y el expediente vive igual, solo en el CRM;
|
|
# - si el encolado o el despacho fallan, no se propaga el error: el barrido del beat
|
|
# recoge lo pendiente. Un sistema de terceros caído no puede romper un alta.
|
|
# El import es diferido para no acoplar el arranque del módulo del expediente al carril.
|
|
from ..expediente_gateway import service as gateway
|
|
from ..expediente_gateway.storage import storage_token
|
|
|
|
case.efc_storage_token = storage_token(company_id, case.reference)
|
|
db.flush()
|
|
gateway.replicate_expediente_best_effort(db, case)
|
|
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
|