home / skills / jpropato / ssg-santalucia / delivery-module

delivery-module skill

/.agent/skills/delivery-module

This skill enforces delivery module business rules, auto-detects shifts, computes trip kilometers, and generates monthly settlements.

npx playbooks add skill jpropato/ssg-santalucia --skill delivery-module

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
5.7 KB
---
name: delivery-module
description: Reglas de negocio y lógica específica del módulo de Delivery
---

# Módulo Delivery - Reglas de Negocio

## Entidades Principales

### Motoquero
- Tiene nombre, estado (activo/inactivo), turnos asignados
- Un motoquero puede trabajar en DÍA, NOCHE o AMBOS
- Solo aparece en el selector de viajes si está asignado al turno correspondiente

### Viaje
- Representa una salida del motoquero con uno o varios pedidos
- Tiene turno (DÍA/NOCHE) auto-detectado por hora del sistema
- Contiene múltiples direcciones pero solo se computa la más lejana
- Estados: BORRADOR → CONFIRMADO → LIQUIDADO

### Liquidación
- Agrupa viajes de un período (mes) y turno
- Genera ranking por km totales
- Aplica multiplicadores según posición
- Estados: BORRADOR → CERRADA → ABONADA

## Cálculo de Kilómetros

### Regla Principal
Solo se computan km de **IDA** (Local → dirección más lejana).
NO es ida y vuelta.

### Cálculo con múltiples direcciones
```typescript
// Pseudocódigo
const direcciones = ['Dir A', 'Dir B', 'Dir C'];
const kmPorDireccion = await Promise.all(
  direcciones.map(d => mapsService.calcularDistancia(d))
);
// [3.2, 5.1, 4.0]

const kmViaje = Math.max(...kmPorDireccion); // 5.1 km
```

### Caché de direcciones
- Antes de consultar Google Maps, buscar en tabla `DireccionCache`
- Normalizar dirección: mayúsculas, sin acentos, sin puntos
- Si existe y tiene < 90 días, usar valor cacheado
- Si no existe o expiró, consultar y guardar

## Turnos

### Definición
- **DÍA**: Hora del viaje < hora de corte (configurable, default 18:00)
- **NOCHE**: Hora del viaje >= hora de corte

### Auto-detección
```typescript
function detectarTurno(fecha: Date): 'DIA' | 'NOCHE' {
  const horaCorte = await configService.get('hora_corte_turno'); // "18:00"
  const [h, m] = horaCorte.split(':').map(Number);
  
  const hora = fecha.getHours();
  const minutos = fecha.getMinutes();
  
  if (hora < h || (hora === h && minutos < m)) {
    return 'DIA';
  }
  return 'NOCHE';
}
```

### Filtro de motoqueros
Al cargar un viaje, solo mostrar motoqueros que tengan el turno actual en su array de `turnos`.

## Liquidación Mensual

### Flujo
1. Usuario selecciona TURNO (Día o Noche)
2. Sistema obtiene viajes CONFIRMADOS del mes para ese turno
3. Agrupa por motoquero y suma km totales
4. Genera RANKING (mayor a menor km)
5. Asigna MULTIPLICADOR según posición
6. Calcula pago: `km × multiplicador × precio_km`
7. Identifica mayor cantidad de PEDIDOS → asigna BONO
8. Admin puede AJUSTAR manualmente
9. Confirma → Liquidación CERRADA
10. Marca como ABONADA cuando se paga

### Fórmula de Pago
```typescript
interface LiquidacionItem {
  motoqueroId: string;
  kmTotales: number;
  cantidadViajes: number;
  cantidadPedidos: number;
  ranking: number;
  multiplicador: number;
  subtotal: number;     // km × multiplicador × precio_km
  bono: number;         // 0 o (20 × precio_nafta)
  total: number;        // subtotal + bono
}

function calcularLiquidacion(viajes: Viaje[], params: LiquidacionParams) {
  // 1. Agrupar por motoquero
  const porMotoquero = groupBy(viajes, 'motoqueroId');
  
  // 2. Calcular totales
  const items = Object.entries(porMotoquero).map(([motoId, viajes]) => ({
    motoqueroId: motoId,
    kmTotales: sum(viajes, 'kmTotal'),
    cantidadViajes: viajes.length,
    cantidadPedidos: sum(viajes, 'cantidadPedidos'),
  }));
  
  // 3. Ordenar por km (ranking)
  items.sort((a, b) => b.kmTotales - a.kmTotales);
  
  // 4. Asignar multiplicadores
  const multiplicadores = [
    params.multiplicador1, // 5
    params.multiplicador2, // 3
    params.multiplicador3, // 2
  ];
  
  items.forEach((item, i) => {
    item.ranking = i + 1;
    item.multiplicador = multiplicadores[i] ?? params.multiplicadorDefault; // 1
    item.subtotal = item.kmTotales * item.multiplicador * params.precioKm;
  });
  
  // 5. Asignar bono al de más pedidos
  const maxPedidos = Math.max(...items.map(i => i.cantidadPedidos));
  const ganadoresBono = items.filter(i => i.cantidadPedidos === maxPedidos);
  const bono = params.multiplicadorBono * params.precioNafta; // 20 * 1200 = 24000
  const bonoPorGanador = bono / ganadoresBono.length;
  
  ganadoresBono.forEach(item => {
    item.bono = bonoPorGanador;
  });
  
  // 6. Calcular totales
  items.forEach(item => {
    item.total = item.subtotal + (item.bono ?? 0);
  });
  
  return items;
}
```

### Ajustes Manuales
- Admin puede modificar la liquidación antes de cerrarla
- Los ajustes se guardan en `datosAjustados` (JSON)
- Los datos reales permanecen en `datosReales` (JSON)
- Todo cambio queda auditado

### Estados de Liquidación
```
BORRADOR → CERRADA → ABONADA
              ↓
          REABIERTA → CERRADA (v2)
```

## Parámetros del Sistema

Guardados en tabla `Config`:

| Key | Tipo | Ejemplo |
|-----|------|---------|
| `precio_km` | Decimal | 150 |
| `precio_nafta_super` | Decimal | 1200 |
| `multiplicador_bono` | Integer | 20 |
| `hora_corte_turno` | String | "18:00" |
| `multiplicador_ranking_1` | Integer | 5 |
| `multiplicador_ranking_2` | Integer | 3 |
| `multiplicador_ranking_3` | Integer | 2 |
| `multiplicador_ranking_default` | Integer | 1 |
| `direccion_local` | String | "Av. X 123, Caseros" |

## Auditoría

Todo cambio debe registrarse en `AuditLog`:

```typescript
await auditService.log({
  userId: request.user.id,
  action: 'CREATE' | 'UPDATE' | 'DELETE',
  entity: 'Viaje' | 'Motoquero' | 'Liquidacion',
  entityId: entity.id,
  data: { before, after }, // Para UPDATE
});
```

## Generación de PDF

La liquidación debe poder exportarse a PDF con:
- Encabezado (nombre pizzería, período, turno)
- Parámetros aplicados
- Tabla de ranking con km, multiplicadores, subtotales
- Sección de bono
- Resumen por motoquero
- Detalle de viajes (anexo opcional)
- Firma/fecha de generación

Overview

This skill implements delivery-module business rules and domain logic for a delivery system focused on moto couriers. It codifies turn detection, distance calculation, monthly settlement (liquidation) rules, caching, auditing and PDF export requirements. The skill is designed to be deterministic, auditable, and configurable via runtime parameters.

How this skill works

The module auto-detects a trip's shift (DAY/NIGHT) from the trip timestamp and filters available couriers by their assigned shifts. For each trip it computes kilometers using only the outbound leg to the farthest delivery address, consulting a normalized address cache before calling an external maps service. Monthly liquidations aggregate confirmed trips by courier, rank by total km, apply ranking multipliers, allocate a fuel bonus to top order-count performers, support manual adjustments, and produce auditable, exportable reports.

When to use it

  • When assigning couriers and enforcing shift eligibility for trips.
  • When calculating trip distance using only the farthest delivery address (outbound km).
  • When generating monthly settlements that rank couriers and apply multipliers and bonuses.
  • When caching and reusing address-distance lookups to reduce external map API calls.
  • When producing auditable liquidation PDFs for payroll or accounting.

Best practices

  • Normalize addresses (uppercase, remove accents and punctuation) before cache lookup and storage.
  • Validate and expire cached distances older than 90 days to ensure accuracy.
  • Keep system parameters in a Config table and use them for hora_corte, precio_km, multipliers and bonus math.
  • Audit every create/update/delete on core entities (Trip, Courier, Liquidation) with before/after payloads and user metadata.
  • Allow admins to apply manual adjustments stored separately (datosAjustados) while preserving original data (datosReales).

Example use cases

  • Trip creation: detect shift automatically, show only couriers assigned to that shift, compute trip km from farthest address using cache-first lookup.
  • Monthly payroll: gather confirmed trips, group by courier, sort by km, assign ranking multipliers and calculate subtotal and total with bonus allocation.
  • Cache refresh: when an address is not in cache or is expired (>90 days), call maps service, persist normalized address and distance.
  • Admin workflow: create liquidation in DRAFT, apply manual adjustments, close liquidation to CERRADA, and mark ABONADA after payment.
  • Reporting: export liquidation to PDF with header, applied parameters, ranking table, bonus section and per-courier summaries.

FAQ

How is shift determined for a trip?

Shift is auto-detected using the trip timestamp and a configurable cutoff hour (hora_corte_turno), default 18:00; times before cutoff are DAY, otherwise NIGHT.

Which distance is used for a trip with multiple addresses?

Only the distance to the farthest delivery address (outbound) is used; return trips are not counted.