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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user