Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.
Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).
Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
Ganada/Perdida (migración).
Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.
UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).
Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
8.7 KiB
Python
270 lines
8.7 KiB
Python
"""Endpoints del módulo Tarifario."""
|
|
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from core.database import get_core_db
|
|
from core.security import get_current_user
|
|
|
|
from . import service
|
|
from .dto import (
|
|
CostRequest,
|
|
CostResult,
|
|
ImportPreview,
|
|
RateBreakDTO,
|
|
RateChargeCreate,
|
|
RateChargeResponse,
|
|
RateChargeUpdate,
|
|
RateLaneCreate,
|
|
RateLaneResponse,
|
|
RateSheetCreate,
|
|
RateSheetResponse,
|
|
RateSheetUpdate,
|
|
)
|
|
|
|
router = APIRouter(prefix="/rate-sheets", tags=["Tarifario"])
|
|
|
|
|
|
def _ctx(current_user: dict):
|
|
return current_user["tenant_id"], current_user.get("sub") or current_user.get("id")
|
|
|
|
|
|
def _sheet_out(db: Session, tenant_id: int, sheet) -> RateSheetResponse:
|
|
out = RateSheetResponse.model_validate(sheet)
|
|
out.lane_count = service.lane_count(db, tenant_id, sheet.id)
|
|
return out
|
|
|
|
|
|
def _lane_out(db: Session, lane) -> RateLaneResponse:
|
|
out = RateLaneResponse.model_validate(lane)
|
|
out.breaks = [RateBreakDTO.model_validate(b) for b in service.breaks_of(db, lane.id)]
|
|
return out
|
|
|
|
|
|
# ---------------- Tarifarios ----------------
|
|
@router.get("", response_model=list[RateSheetResponse])
|
|
def list_sheets(
|
|
company_id: int = Query(...),
|
|
mode: str | None = Query(None),
|
|
supplier_id: int | None = Query(None),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
sheets = service.list_sheets(db, tenant_id, company_id, mode=mode, supplier_id=supplier_id)
|
|
return [_sheet_out(db, tenant_id, s) for s in sheets]
|
|
|
|
|
|
@router.post("", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
|
|
def create_sheet(
|
|
data: RateSheetCreate,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, user_id = _ctx(current_user)
|
|
sheet = service.create_sheet(db, tenant_id, company_id, data, user_id)
|
|
return _sheet_out(db, tenant_id, sheet)
|
|
|
|
|
|
@router.get("/template")
|
|
def download_template(
|
|
mode: str = Query(..., description="aereo | maritimo_fcl | maritimo_lcl | terrestre"),
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
content = service.build_template(mode)
|
|
return Response(
|
|
content=content,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f'attachment; filename="plantilla_tarifario_{mode}.xlsx"'},
|
|
)
|
|
|
|
|
|
@router.post("/import/preview", response_model=ImportPreview)
|
|
async def import_preview(
|
|
company_id: int = Query(...),
|
|
mode: str = Form(...),
|
|
file: UploadFile = File(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
content = await file.read()
|
|
return service.parse_excel(mode, content)
|
|
|
|
|
|
@router.post("/import", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
|
|
async def import_sheet(
|
|
company_id: int = Query(...),
|
|
mode: str = Form(...),
|
|
name: str = Form(...),
|
|
supplier_id: int | None = Form(None),
|
|
currency: str = Form("USD"),
|
|
valid_from: date | None = Form(None),
|
|
valid_to: date | None = Form(None),
|
|
default_origin: str | None = Form(None),
|
|
file: UploadFile = File(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, user_id = _ctx(current_user)
|
|
content = await file.read()
|
|
header = RateSheetCreate(
|
|
mode=mode, name=name, supplier_id=supplier_id, currency=currency,
|
|
valid_from=valid_from, valid_to=valid_to, default_origin=default_origin,
|
|
)
|
|
sheet = service.import_from_excel(db, tenant_id, company_id, mode, content, header, user_id)
|
|
return _sheet_out(db, tenant_id, sheet)
|
|
|
|
|
|
@router.get("/{sheet_id}", response_model=RateSheetResponse)
|
|
def get_sheet(
|
|
sheet_id: int,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
return _sheet_out(db, tenant_id, service.get_sheet(db, tenant_id, company_id, sheet_id))
|
|
|
|
|
|
@router.patch("/{sheet_id}", response_model=RateSheetResponse)
|
|
def update_sheet(
|
|
sheet_id: int,
|
|
data: RateSheetUpdate,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, user_id = _ctx(current_user)
|
|
return _sheet_out(db, tenant_id, service.update_sheet(db, tenant_id, company_id, sheet_id, data, user_id))
|
|
|
|
|
|
@router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_sheet(
|
|
sheet_id: int,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
service.delete_sheet(db, tenant_id, company_id, sheet_id)
|
|
|
|
|
|
# ---------------- Rutas (lanes) ----------------
|
|
@router.get("/{sheet_id}/lanes", response_model=list[RateLaneResponse])
|
|
def list_lanes(
|
|
sheet_id: int,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
service.get_sheet(db, tenant_id, company_id, sheet_id)
|
|
return [_lane_out(db, lane) for lane in service.list_lanes(db, tenant_id, sheet_id)]
|
|
|
|
|
|
@router.post("/{sheet_id}/lanes", response_model=RateLaneResponse, status_code=status.HTTP_201_CREATED)
|
|
def create_lane(
|
|
sheet_id: int,
|
|
data: RateLaneCreate,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
lane = service.create_lane(db, tenant_id, company_id, sheet_id, data)
|
|
return _lane_out(db, lane)
|
|
|
|
|
|
@router.delete("/{sheet_id}/lanes/{lane_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_lane(
|
|
sheet_id: int,
|
|
lane_id: int,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
service.delete_lane(db, tenant_id, sheet_id, lane_id)
|
|
|
|
|
|
# ---------------- Cargos adicionales ----------------
|
|
@router.get("/{sheet_id}/charges", response_model=list[RateChargeResponse])
|
|
def list_charges(
|
|
sheet_id: int,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
service.get_sheet(db, tenant_id, company_id, sheet_id)
|
|
return service.list_charges(db, tenant_id, sheet_id)
|
|
|
|
|
|
@router.post("/{sheet_id}/charges", response_model=RateChargeResponse, status_code=status.HTTP_201_CREATED)
|
|
def create_charge(
|
|
sheet_id: int,
|
|
data: RateChargeCreate,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
return service.create_charge(db, tenant_id, company_id, sheet_id, data)
|
|
|
|
|
|
@router.patch("/{sheet_id}/charges/{charge_id}", response_model=RateChargeResponse)
|
|
def update_charge(
|
|
sheet_id: int,
|
|
charge_id: int,
|
|
data: RateChargeUpdate,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
return service.update_charge(db, tenant_id, sheet_id, charge_id, data)
|
|
|
|
|
|
@router.delete("/{sheet_id}/charges/{charge_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_charge(
|
|
sheet_id: int,
|
|
charge_id: int,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
tenant_id, _ = _ctx(current_user)
|
|
service.delete_charge(db, tenant_id, sheet_id, charge_id)
|
|
|
|
|
|
# ---------------- Motor de costeo ----------------
|
|
cost_router = APIRouter(tags=["Tarifario"])
|
|
|
|
|
|
@cost_router.post("/rate-quote", response_model=CostResult)
|
|
def rate_quote(
|
|
req: CostRequest,
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
"""Calcula opciones de costo (por proveedor) para una ruta/carga."""
|
|
tenant_id, _ = _ctx(current_user)
|
|
options = service.quote_cost(db, tenant_id, company_id, req)
|
|
return CostResult(request=req, options=options)
|
|
|
|
|
|
@cost_router.get("/rate-locations")
|
|
def rate_locations(
|
|
mode: str = Query(...),
|
|
company_id: int = Query(...),
|
|
current_user: dict = Depends(get_current_user),
|
|
db: Session = Depends(get_core_db),
|
|
):
|
|
"""Orígenes/destinos cotizables (de los tarifarios activos) para alinear el cotizador."""
|
|
tenant_id, _ = _ctx(current_user)
|
|
return service.lane_locations(db, tenant_id, company_id, mode)
|