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:
0
backend/api/v1/modules/crm/catalogs/__init__.py
Normal file
0
backend/api/v1/modules/crm/catalogs/__init__.py
Normal file
37
backend/api/v1/modules/crm/catalogs/data.py
Normal file
37
backend/api/v1/modules/crm/catalogs/data.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Catálogos de referencia del dominio (Incoterms y actores/participantes).
|
||||
|
||||
Se centralizan aquí para administrarlos en un solo lugar y validarlos desde el
|
||||
levantamiento de requerimientos (R-T-10) y modelar los participantes del proceso
|
||||
(R-T-01), incluyendo la autoridad aduanera.
|
||||
"""
|
||||
|
||||
# Incoterms 2020 (R-T-10)
|
||||
INCOTERMS: list[dict] = [
|
||||
{"code": "EXW", "name": "Ex Works — En fábrica"},
|
||||
{"code": "FCA", "name": "Free Carrier — Franco transportista"},
|
||||
{"code": "FAS", "name": "Free Alongside Ship — Franco al costado del buque"},
|
||||
{"code": "FOB", "name": "Free On Board — Franco a bordo"},
|
||||
{"code": "CFR", "name": "Cost and Freight — Costo y flete"},
|
||||
{"code": "CIF", "name": "Cost, Insurance and Freight — Costo, seguro y flete"},
|
||||
{"code": "CPT", "name": "Carriage Paid To — Transporte pagado hasta"},
|
||||
{"code": "CIP", "name": "Carriage and Insurance Paid To — Transporte y seguro pagados hasta"},
|
||||
{"code": "DAP", "name": "Delivered At Place — Entregado en lugar"},
|
||||
{"code": "DPU", "name": "Delivered At Place Unloaded — Entregado en lugar descargado"},
|
||||
{"code": "DDP", "name": "Delivered Duty Paid — Entregado con derechos pagados"},
|
||||
]
|
||||
|
||||
INCOTERM_CODES: set[str] = {i["code"] for i in INCOTERMS}
|
||||
|
||||
# Roles/actores del proceso (R-T-01). Los actores externos se administran como
|
||||
# proveedores (crm.suppliers) vía su clasificación; el cliente/prospecto como cuenta.
|
||||
PARTICIPANT_ROLES: list[dict] = [
|
||||
{"code": "exportador", "label": "Exportador", "source": "account"},
|
||||
{"code": "importador", "label": "Importador", "source": "account"},
|
||||
{"code": "agente_carga", "label": "Agente de carga", "source": "supplier"},
|
||||
{"code": "agente_aduanal", "label": "Agente aduanal", "source": "supplier"},
|
||||
{"code": "naviera", "label": "Naviera", "source": "supplier"},
|
||||
{"code": "aerolinea", "label": "Aerolínea", "source": "supplier"},
|
||||
{"code": "transportista_terrestre", "label": "Transportista terrestre", "source": "supplier"},
|
||||
{"code": "agente_corresponsal", "label": "Agente corresponsal", "source": "supplier"},
|
||||
{"code": "autoridad_aduanera", "label": "Autoridad aduanera", "source": "supplier"},
|
||||
]
|
||||
91
backend/api/v1/modules/crm/catalogs/routes.py
Normal file
91
backend/api/v1/modules/crm/catalogs/routes.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10)."""
|
||||
|
||||
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 ..accounts.models import Account
|
||||
from ..suppliers.models import Supplier
|
||||
from .data import INCOTERMS, PARTICIPANT_ROLES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/catalogs/incoterms")
|
||||
def list_incoterms(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Catálogo de Incoterms 2020 (R-T-10)."""
|
||||
return INCOTERMS
|
||||
|
||||
|
||||
@router.get("/catalogs/participant-roles")
|
||||
def list_participant_roles(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Catálogo de roles/actores del proceso, incluida la autoridad aduanera (R-T-01)."""
|
||||
return PARTICIPANT_ROLES
|
||||
|
||||
|
||||
@router.get("/participants")
|
||||
def list_participants(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
role: str | None = Query(None, description="Filtra por rol/clasificación"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Vista unificada de participantes del proceso: clientes/prospectos (cuentas) y
|
||||
actores externos (proveedores por clasificación), en un solo catálogo (R-T-01)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
result: list[dict] = []
|
||||
|
||||
accounts = (
|
||||
db.query(Account)
|
||||
.filter(
|
||||
Account.tenant_id == tenant_id,
|
||||
Account.company_id == company_id,
|
||||
Account.deleted_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for acc in accounts:
|
||||
acc_role = getattr(acc, "record_type", None) or "cliente"
|
||||
if role and role not in (acc_role, "exportador", "importador"):
|
||||
# Las cuentas representan exportador/importador/cliente; sólo se omiten
|
||||
# cuando el filtro pide explícitamente un rol de proveedor.
|
||||
if role not in ("exportador", "importador", "cliente", "prospecto"):
|
||||
continue
|
||||
result.append({
|
||||
"id": acc.id,
|
||||
"source": "account",
|
||||
"name": acc.name,
|
||||
"role": acc_role,
|
||||
"roles": [acc_role],
|
||||
})
|
||||
|
||||
suppliers = (
|
||||
db.query(Supplier)
|
||||
.filter(
|
||||
Supplier.tenant_id == tenant_id,
|
||||
Supplier.company_id == company_id,
|
||||
Supplier.deleted_at.is_(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for sup in suppliers:
|
||||
classifications = sup.classifications or []
|
||||
if role and role not in classifications:
|
||||
continue
|
||||
result.append({
|
||||
"id": sup.id,
|
||||
"source": "supplier",
|
||||
"name": sup.name,
|
||||
"role": classifications[0] if classifications else "proveedor",
|
||||
"roles": classifications,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -98,6 +98,17 @@ def reject_quote(
|
||||
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post("/quotes/{quote_id}/clone", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
|
||||
def clone_quote(
|
||||
quote_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Clona la cotización como borrador para re-cotizar (R-C-12)."""
|
||||
return service.clone_quote(db, quote_id, current_user["tenant_id"], company_id, _user_id(current_user))
|
||||
|
||||
|
||||
@router.delete("/quotes/{quote_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_quote(
|
||||
quote_id: int,
|
||||
|
||||
@@ -158,6 +158,56 @@ def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) ->
|
||||
return quote
|
||||
|
||||
|
||||
def clone_quote(
|
||||
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Quote:
|
||||
"""Clona una cotización (y sus conceptos) como borrador para re-cotizar (R-C-12).
|
||||
|
||||
Si la cotización origen fue rechazada, reabre su solicitud a 'en_analisis' para
|
||||
cerrar el ciclo de reintento del Diagrama 1.
|
||||
"""
|
||||
src = get_quote(db, quote_id, tenant_id, company_id)
|
||||
new_quote = Quote(
|
||||
reference=(f"{src.reference}-R" if src.reference else None),
|
||||
service_request_id=src.service_request_id,
|
||||
account_id=src.account_id,
|
||||
currency=src.currency,
|
||||
status="borrador",
|
||||
valid_until=src.valid_until,
|
||||
notes=src.notes,
|
||||
terms=src.terms,
|
||||
owner_user_id=src.owner_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(new_quote)
|
||||
db.flush()
|
||||
src_items = (
|
||||
db.query(QuoteItem)
|
||||
.filter(QuoteItem.quote_id == src.id, QuoteItem.deleted_at.is_(None))
|
||||
.all()
|
||||
)
|
||||
for it in src_items:
|
||||
db.add(QuoteItem(
|
||||
quote_id=new_quote.id, concept=it.concept, description=it.description,
|
||||
supplier_id=it.supplier_id, quantity=it.quantity, unit_cost=it.unit_cost,
|
||||
unit_sale=it.unit_sale, currency=it.currency,
|
||||
tenant_id=tenant_id, company_id=company_id,
|
||||
))
|
||||
db.flush()
|
||||
_recompute_totals(db, new_quote)
|
||||
# Reabre la solicitud origen para el ciclo de re-cotización
|
||||
if src.service_request_id:
|
||||
sr = db.query(ServiceRequest).filter(ServiceRequest.id == src.service_request_id).first()
|
||||
if sr and sr.status in ("rechazada", "cotizada"):
|
||||
sr.status = "en_analisis"
|
||||
db.commit()
|
||||
db.refresh(new_quote)
|
||||
return new_quote
|
||||
|
||||
|
||||
# ----- Quote items -----
|
||||
|
||||
def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]:
|
||||
|
||||
@@ -5,12 +5,15 @@ Importar este módulo también registra los permisos del CRM (side-effect de
|
||||
``permissions``), siguiendo el patrón del ``PermissionRegistry``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
|
||||
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 .catalogs.routes import router as catalogs_router
|
||||
from .contacts.routes import router as contacts_router
|
||||
from .documents.routes import router as documents_router
|
||||
from .leads.routes import router as leads_router
|
||||
@@ -22,7 +25,10 @@ from .service_requests.routes import router as service_requests_router
|
||||
from .suppliers.routes import router as suppliers_router
|
||||
from .uploads.routes import router as uploads_router
|
||||
|
||||
router = APIRouter()
|
||||
# Enforcement por área/carril (R-T-07): se exige el permiso crm.access para tocar
|
||||
# cualquier endpoint del módulo. En desarrollo el usuario se auto-bootstrapea a
|
||||
# super_admin (ver PermissionChecker) para no bloquear el entorno.
|
||||
router = APIRouter(dependencies=[Depends(PermissionChecker(["crm.access"]))])
|
||||
|
||||
router.include_router(accounts_router)
|
||||
router.include_router(suppliers_router)
|
||||
@@ -36,4 +42,5 @@ router.include_router(pipelines_router)
|
||||
router.include_router(opportunities_router)
|
||||
router.include_router(activities_router)
|
||||
router.include_router(metrics_router)
|
||||
router.include_router(catalogs_router)
|
||||
router.include_router(uploads_router)
|
||||
|
||||
@@ -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