Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/quotes/service.py
Aduanasoft e79705e6e3 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>
2026-07-15 08:46:53 -06:00

282 lines
9.6 KiB
Python

from datetime import datetime, timezone
from decimal import Decimal
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from ..suppliers.models import Supplier
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
from .models import Quote, QuoteItem
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
if _id is None:
return True
return (
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
is not None
)
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
if not _exists(db, Account, data.get("account_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
if not _exists(db, ServiceRequest, data.get("service_request_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La solicitud asociada no existe")
def _recompute_totals(db: Session, quote: Quote) -> None:
"""Recalcula total_cost/total_sale a partir de los conceptos vigentes."""
cost, sale = (
db.query(
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_cost), 0),
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_sale), 0),
)
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
.one()
)
quote.total_cost = Decimal(cost or 0)
quote.total_sale = Decimal(sale or 0)
# ----- Quotes -----
def get_quotes(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
quote_status: str | None = None,
account_id: int | None = None,
) -> list[Quote]:
query = db.query(Quote).filter(
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
if quote_status:
query = query.filter(Quote.status == quote_status)
if account_id is not None:
query = query.filter(Quote.account_id == account_id)
if search:
query = query.filter(Quote.reference.ilike(f"%{search}%"))
return query.order_by(Quote.created_at.desc()).all()
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
obj = (
db.query(Quote)
.filter(
Quote.id == quote_id,
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
return obj
def create_quote(
db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Quote(**data, 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 update_quote(
db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
obj = get_quote(db, quote_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> None:
obj = get_quote(db, quote_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def _set_service_request_status(db: Session, quote: Quote, new_status: str) -> None:
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
if sr:
sr.status = new_status
def send_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "enviada"
quote.sent_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "cotizada")
db.commit()
db.refresh(quote)
return quote
def accept_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "aceptada"
quote.accepted_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "aceptada")
db.commit()
db.refresh(quote)
return quote
def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "rechazada"
quote.rejected_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "rechazada")
db.commit()
db.refresh(quote)
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]:
get_quote(db, quote_id, tenant_id, company_id) # valida scope
return (
db.query(QuoteItem)
.filter(
QuoteItem.quote_id == quote_id,
QuoteItem.tenant_id == tenant_id,
QuoteItem.company_id == company_id,
QuoteItem.deleted_at.is_(None),
)
.order_by(QuoteItem.id.asc())
.all()
)
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> QuoteItem:
item = (
db.query(QuoteItem)
.filter(
QuoteItem.id == item_id,
QuoteItem.tenant_id == tenant_id,
QuoteItem.company_id == company_id,
QuoteItem.deleted_at.is_(None),
)
.first()
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
return item
def create_quote_item(db: Session, payload: QuoteItemCreate, tenant_id: int, company_id: int) -> QuoteItem:
quote = get_quote(db, payload.quote_id, tenant_id, company_id)
if not _exists(db, Supplier, payload.supplier_id, tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
item = QuoteItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(item)
db.flush()
_recompute_totals(db, quote)
db.commit()
db.refresh(item)
return item
def update_quote_item(
db: Session, item_id: int, payload: QuoteItemUpdate, tenant_id: int, company_id: int
) -> QuoteItem:
item = _get_item(db, item_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
for field, value in data.items():
setattr(item, field, value)
db.flush()
quote = get_quote(db, item.quote_id, tenant_id, company_id)
_recompute_totals(db, quote)
db.commit()
db.refresh(item)
return item
def delete_quote_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None:
item = _get_item(db, item_id, tenant_id, company_id)
quote_id = item.quote_id
item.deleted_at = datetime.now(timezone.utc)
db.flush()
quote = get_quote(db, quote_id, tenant_id, company_id)
_recompute_totals(db, quote)
db.commit()