feat(ops,fin,crm): reglas de negocio del PDF (decisiones, cierre, facturación, continuidad, RBAC)

Cierra los huecos de la auditoría contra "SOFTWARE PARA AGENTES DE CARGA":

- ops (Diag. 2/3): bitácora con puntos de decisión (kind=decision) y ciclo de
  corrección (parent_event_id/attempt) para ¿Cut Off? y ¿despacho autorizado?
  (R-E-05/13, R-I-06). Reprogramación de salida (previous_etd, R-E-06). Hitos
  operativos completos export/import. Cierre operativo con costos finales
  (close_shipment, R-E-22).
- fin (Diag. 4): facturación con gate por cierre operativo y sin duplicar
  (R-F-01), costos de operación arrastrados (ops_cost_total, R-F-02), envío con
  PDF generado y guardado en MinIO (send_invoice + pdf.py sin dependencias,
  R-F-05) y revisión del cliente (en_revision_cliente + aprobación, R-F-06).
- crm (Diag. 1): opportunity_id enlaza embudo→RFQ (R-C-02), contacto como etapa
  (first_contact_at, R-C-04), re-cotización (clone_quote + reopen, R-C-12).
- transversal: catálogo de Incoterms y participantes/actores incl. autoridad
  aduanera (R-T-01/10), enforcement de permisos por carril (RBAC) con roles
  sembrados y dependencias dev-safe (R-T-07).
- Migración d5e6f7a8b9c0 con downgrade. Seed extendido. 70 tests (12 nuevos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-15 08:46:53 -06:00
parent 116d2e7f5a
commit e79705e6e3
26 changed files with 1407 additions and 68 deletions

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
class ServiceRequestBase(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
opportunity_id: int | None = None
operation_type: str = Field(..., max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
@@ -31,9 +32,26 @@ class ServiceRequestCreate(ServiceRequestBase):
pass
class ServiceRequestContactInput(BaseModel):
"""Registro del contacto al cliente como etapa del flujo comercial (R-C-04)."""
notes: str | None = None
class ServiceRequestFromOpportunityInput(BaseModel):
"""Datos para convertir una oportunidad del embudo en solicitud/RFQ (R-C-02)."""
operation_type: str = Field(..., max_length=20) # importacion | exportacion
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
notes: str | None = None
class ServiceRequestUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
opportunity_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
@@ -58,6 +76,8 @@ class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
first_contact_at: datetime | None = None
first_contact_notes: str | None = None
tenant_id: int
company_id: int
created_by: str | None = None

View File

@@ -1,6 +1,6 @@
from datetime import date
from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -22,6 +22,10 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
# Oportunidad de origen: enlaza el embudo (primer contacto) con la cadena RFQ→cotización (R-C-02)
opportunity_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.opportunities.id"), nullable=True, index=True
)
# importacion | exportacion
operation_type: Mapped[str] = mapped_column(String(20), nullable=False)
# maritimo | aereo | terrestre | ferroviario | multimodal
@@ -43,7 +47,10 @@ class ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
Integer, ForeignKey("crm.suppliers.id"), nullable=True
)
requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # otros requerimientos
# nueva | en_analisis | cotizada | aceptada | rechazada | liberada
# Contacto al cliente como etapa del flujo comercial (Diagrama 1, paso 2 — R-C-04)
first_contact_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
first_contact_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# nueva | contacto | en_analisis | cotizada | aceptada | rechazada | liberada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'nueva'"), index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)

View File

@@ -9,7 +9,9 @@ from .dto import (
RateRequestCreate,
RateRequestResponse,
RateRequestUpdate,
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
ServiceRequestResponse,
ServiceRequestUpdate,
)
@@ -71,6 +73,44 @@ def update_service_request(
return service.update_service_request(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/from-opportunity", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED)
def create_from_opportunity(
payload: ServiceRequestFromOpportunityInput,
opportunity_id: int = Query(..., description="Oportunidad a convertir en solicitud"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Convierte una oportunidad del embudo en solicitud/RFQ enlazada (R-C-02)."""
tenant_id = current_user["tenant_id"]
return service.create_from_opportunity(db, opportunity_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/{request_id}/contact", response_model=ServiceRequestResponse)
def register_contact(
request_id: int,
payload: ServiceRequestContactInput,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
tenant_id = current_user["tenant_id"]
return service.register_contact(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/service-requests/{request_id}/requote", response_model=ServiceRequestResponse)
def reopen_for_requote(
request_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
tenant_id = current_user["tenant_id"]
return service.reopen_for_requote(db, request_id, tenant_id, company_id, _user_id(current_user))
@router.delete("/service-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_service_request(
request_id: int,

View File

@@ -4,8 +4,17 @@ from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..catalogs.data import INCOTERM_CODES
from ..opportunities.models import Opportunity
from ..suppliers.models import Supplier
from .dto import RateRequestCreate, RateRequestUpdate, ServiceRequestCreate, ServiceRequestUpdate
from .dto import (
RateRequestCreate,
RateRequestUpdate,
ServiceRequestContactInput,
ServiceRequestCreate,
ServiceRequestFromOpportunityInput,
ServiceRequestUpdate,
)
from .models import RateRequest, ServiceRequest
@@ -30,6 +39,14 @@ def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
if not _exists(db, Supplier, data.get("destination_agent_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe")
if not _exists(db, Opportunity, data.get("opportunity_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La oportunidad asociada no existe")
incoterm = data.get("incoterm")
if incoterm and incoterm not in INCOTERM_CODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Incoterm inválido: usa uno del catálogo ({', '.join(sorted(INCOTERM_CODES))})",
)
# ----- Service requests (RFQ) -----
@@ -112,6 +129,83 @@ def delete_service_request(db: Session, request_id: int, tenant_id: int, company
db.commit()
def register_contact(
db: Session, request_id: int, payload: ServiceRequestContactInput, tenant_id: int, company_id: int,
user_id: str | None = None,
) -> ServiceRequest:
"""Registra el contacto al cliente como etapa del flujo comercial (R-C-04)."""
obj = get_service_request(db, request_id, tenant_id, company_id)
obj.first_contact_at = datetime.now(timezone.utc)
obj.first_contact_notes = payload.notes
if obj.status == "nueva":
obj.status = "contacto"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def create_from_opportunity(
db: Session, opportunity_id: int, payload: ServiceRequestFromOpportunityInput,
tenant_id: int, company_id: int, user_id: str | None = None,
) -> ServiceRequest:
"""Convierte una oportunidad del embudo en una solicitud/RFQ enlazada (R-C-02).
Da continuidad al hilo comercial: el embudo (primer contacto) queda ligado a la
cadena RFQ → cotización → embarque vía ``opportunity_id``.
"""
opp = (
db.query(Opportunity)
.filter(
Opportunity.id == opportunity_id,
Opportunity.tenant_id == tenant_id,
Opportunity.company_id == company_id,
Opportunity.deleted_at.is_(None),
)
.first()
)
if not opp:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
obj = ServiceRequest(
account_id=opp.account_id,
opportunity_id=opp.id,
operation_type=payload.operation_type,
transport_mode=payload.transport_mode,
service_type=payload.service_type,
incoterm=payload.incoterm,
origin=payload.origin,
destination=payload.destination,
status="nueva",
notes=payload.notes,
owner_user_id=opp.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def reopen_for_requote(
db: Session, request_id: int, tenant_id: int, company_id: int, user_id: str | None = None
) -> ServiceRequest:
"""Reabre una solicitud rechazada para volver a cotizar (R-C-12)."""
obj = get_service_request(db, request_id, tenant_id, company_id)
if obj.status not in ("rechazada", "cotizada"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Solo una solicitud rechazada o cotizada puede reabrirse para re-cotizar",
)
obj.status = "en_analisis"
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
# ----- Rate requests -----
def get_rate_requests(