feat(crm,ops): proceso comercial (solicitudes/RFQ, tarifas, cotizaciones) + Operaciones (embarques)

Diagrama 1 (CRM comercial) y Diagrama 2 (Operaciones) del spec de agente de carga:
- crm.service_requests (RFQ) + crm.rate_requests (solicitud de tarifas a proveedores)
- crm.quotes + crm.quote_items: conceptos costo/venta/margen, totales automáticos,
  estados borrador→enviada→aceptada/rechazada
- schema ops: ops.shipments (booking, Cut Off, ETD/ETA, naviera/agente aduanal/destino)
  y ops.shipment_documents (MBL/HBL, MAWB/HAWB, CMR…)
- liberar-a-operaciones: crea el embarque desde la cotización aceptada
- migración b3c4d5e6f7a8, routers/permisos, seed del flujo completo
- 52 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-14 18:21:25 -06:00
parent c26e04bcff
commit a196c44fae
27 changed files with 2090 additions and 1 deletions

View File

@@ -22,7 +22,10 @@ from api.v1.modules.crm.documents.models import Document
from api.v1.modules.crm.leads.models import Lead
from api.v1.modules.crm.opportunities.models import Opportunity
from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument
from core.database import CoreSessionLocal
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
@@ -254,6 +257,72 @@ def seed_suppliers_and_related(db) -> None:
print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor")
def seed_commercial_and_ops(db) -> None:
"""Demo del flujo comercial: Solicitud → Cotización (aceptada) → Embarque liberado."""
if db.query(ServiceRequest).filter(
ServiceRequest.tenant_id == TENANT_ID, ServiceRequest.company_id == COMPANY_ID,
ServiceRequest.deleted_at.is_(None),
).first():
print("• Ya existe flujo comercial; se omite")
return
account = (
db.query(Account)
.filter(Account.tenant_id == TENANT_ID, Account.company_id == COMPANY_ID, Account.deleted_at.is_(None))
.order_by(Account.id.asc())
.first()
)
account_id = account.id if account else None
# 1. Solicitud de servicio (RFQ)
sr = ServiceRequest(
reference="SOL-0001", account_id=account_id, operation_type="exportacion",
transport_mode="maritimo", service_type="puerta_puerta", incoterm="FOB",
origin="Manzanillo, MX", destination="Long Beach, US", cargo_type="Carga general",
load_type="FCL", container_equipment="1x40'HC", status="cotizada",
tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(sr)
db.flush()
# 2. Cotización aceptada con conceptos
quote = Quote(
reference="COT-0001", service_request_id=sr.id, account_id=account_id, currency="USD",
status="aceptada", tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(quote)
db.flush()
items = [
("flete_internacional", 1, 1800, 2200),
("transporte_terrestre", 1, 350, 500),
("despacho_aduanal", 1, 200, 320),
]
total_cost = total_sale = 0
for concept, qty, cost, sale in items:
db.add(QuoteItem(quote_id=quote.id, concept=concept, quantity=qty, unit_cost=cost,
unit_sale=sale, currency="USD", tenant_id=TENANT_ID, company_id=COMPANY_ID))
total_cost += qty * cost
total_sale += qty * sale
quote.total_cost = total_cost
quote.total_sale = total_sale
sr.status = "liberada"
# 3. Embarque liberado a Operaciones
shipment = Shipment(
reference="EMB-0001", quote_id=quote.id, service_request_id=sr.id, account_id=account_id,
operation_type=sr.operation_type, transport_mode=sr.transport_mode, service_type=sr.service_type,
incoterm=sr.incoterm, origin=sr.origin, destination=sr.destination,
status="booking", booking_number="BKG-778812", tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(shipment)
db.flush()
db.add(ShipmentDocument(shipment_id=shipment.id, doc_kind="master", doc_type="MBL",
number="MBLU12345678", tenant_id=TENANT_ID, company_id=COMPANY_ID))
db.commit()
print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL")
def main() -> None:
db = CoreSessionLocal()
try:
@@ -261,6 +330,7 @@ def main() -> None:
pipeline, stages = ensure_pipeline(db)
seed_sample_data(db, pipeline, stages)
seed_suppliers_and_related(db)
seed_commercial_and_ops(db)
print("\nSeed CRM completado.")
finally:
db.close()