From ef7e69ed57f0b65cca40baa814ff62ebf10c03cc Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Mon, 27 Jul 2026 09:46:25 -0600 Subject: [PATCH] =?UTF-8?q?feat(crm):=20tarifario=20=E2=80=94=20editor=20d?= =?UTF-8?q?e=20cargos=20adicionales=20+=20alta=20manual=20de=20rutas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: CRUD de cargos (rate_charges) por tarifario. - Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/v1/modules/crm/rates/dto.py | 26 +++ backend/api/v1/modules/crm/rates/routes.py | 53 ++++++ backend/api/v1/modules/crm/rates/service.py | 54 ++++++ frontend/src/lib/api/crm/rates.ts | 9 + .../crm/tarifarios/[id]/+page.svelte | 172 +++++++++++++++++- 5 files changed, 306 insertions(+), 8 deletions(-) diff --git a/backend/api/v1/modules/crm/rates/dto.py b/backend/api/v1/modules/crm/rates/dto.py index 7daf47a..a5aec50 100644 --- a/backend/api/v1/modules/crm/rates/dto.py +++ b/backend/api/v1/modules/crm/rates/dto.py @@ -21,6 +21,32 @@ class RateChargeDTO(BaseModel): condition: str | None = None +class RateChargeCreate(BaseModel): + concept: str = Field(..., max_length=60) + charge_type: str = Field("fijo", max_length=20) + value: Decimal | None = None + condition: str | None = None + rate_lane_id: int | None = None + + +class RateChargeUpdate(BaseModel): + concept: str | None = Field(None, max_length=60) + charge_type: str | None = Field(None, max_length=20) + value: Decimal | None = None + condition: str | None = None + + +class RateChargeResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + rate_sheet_id: int | None + rate_lane_id: int | None + concept: str + charge_type: str + value: Decimal | None + condition: str | None + + # ---------- Rutas ---------- class RateLaneBase(BaseModel): origin: str | None = Field(None, max_length=20) diff --git a/backend/api/v1/modules/crm/rates/routes.py b/backend/api/v1/modules/crm/rates/routes.py index 7fb32d5..28d46e7 100644 --- a/backend/api/v1/modules/crm/rates/routes.py +++ b/backend/api/v1/modules/crm/rates/routes.py @@ -14,6 +14,9 @@ from .dto import ( CostResult, ImportPreview, RateBreakDTO, + RateChargeCreate, + RateChargeResponse, + RateChargeUpdate, RateLaneCreate, RateLaneResponse, RateSheetCreate, @@ -187,6 +190,56 @@ def delete_lane( 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"]) diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py index 49f15c1..8804e30 100644 --- a/backend/api/v1/modules/crm/rates/service.py +++ b/backend/api/v1/modules/crm/rates/service.py @@ -149,6 +149,60 @@ def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> Non db.commit() +# ============================================================ Cargos adicionales +def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]: + return ( + db.query(RateCharge) + .filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id, + RateCharge.deleted_at.is_(None)) + .order_by(RateCharge.concept) + .all() + ) + + +def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge: + get_sheet(db, tenant_id, company_id, sheet_id) + ch = RateCharge( + tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, + rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type, + value=data.value, condition=data.condition, + ) + db.add(ch) + db.commit() + db.refresh(ch) + return ch + + +def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge: + ch = ( + db.query(RateCharge) + .filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id, + RateCharge.tenant_id == tenant_id) + .first() + ) + if not ch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(ch, field, value) + db.commit() + db.refresh(ch) + return ch + + +def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None: + from sqlalchemy import func + ch = ( + db.query(RateCharge) + .filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id, + RateCharge.tenant_id == tenant_id) + .first() + ) + if not ch: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado") + ch.deleted_at = func.now() + db.commit() + + # ============================================================ Importación Excel # Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre). TEMPLATES: dict[str, list[str]] = { diff --git a/frontend/src/lib/api/crm/rates.ts b/frontend/src/lib/api/crm/rates.ts index 4a3ba52..9c4c8dd 100644 --- a/frontend/src/lib/api/crm/rates.ts +++ b/frontend/src/lib/api/crm/rates.ts @@ -24,6 +24,12 @@ export type RateSheetInput = Partial; ok: boolean; warnings: string[]; errors: string[]; } export interface ImportPreview { mode: RateMode; total: number; valid: number; rows: ImportPreviewRow[]; columns: string[]; } @@ -61,6 +67,9 @@ export const rateSheetsAPI = { lanes: (id: number, companyId: number) => unwrap(api.get(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`)), addLane: (id: number, data: Partial, companyId: number) => unwrap(api.post(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`, data)), removeLane: (id: number, laneId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/lanes/${laneId}?${qp(companyId)}`)), + charges: (id: number, companyId: number) => unwrap(api.get(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`)), + addCharge: (id: number, data: RateChargeInput, companyId: number) => unwrap(api.post(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`, data)), + removeCharge: (id: number, chargeId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/charges/${chargeId}?${qp(companyId)}`)), /** Descarga la plantilla Excel del modo. */ async downloadTemplate(mode: RateMode, companyId: number): Promise { diff --git a/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte index 41190f9..0b123fa 100644 --- a/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/tarifarios/[id]/+page.svelte @@ -1,29 +1,48 @@
@@ -64,12 +135,15 @@
- Rutas ({lanes.length}) - El motor de cotización usa estas rutas cuando el tarifario está activo y vigente. + +
Rutas ({lanes.length}) + El motor de cotización usa estas rutas cuando el tarifario está activo y vigente. +
+
{#if lanes.length === 0} -

Sin rutas.

+

Sin rutas. Agrega una o importa por Excel.

{:else}
@@ -93,5 +167,87 @@ {/if} + + + Cargos adicionales ({charges.length}) + Recargos que el motor suma a la tarifa base (combustible, DGR, THC, maniobras…). + + +
+ + + + + +
+ {#if charges.length === 0} +

Sin cargos adicionales.

+ {:else} + + ConceptoTipoValorCondición + + {#each charges as c (c.id)} + + {conceptLabel(c.concept)} + {chargeTypeLabel(c.charge_type)} + {c.value ?? '—'}{c.charge_type === 'porcentaje' ? ' %' : ''} + {c.condition ?? '—'} + + + {/each} + + + {/if} +
+
{/if}
+ +{#if showLane && sheet} + +{/if}