Asignado a: Juan Miguel Revisado por: Luis Pallares Cliente: TLC (Eduardo Galvan) Estimado: ~10 horas
TLC mueve contenedores entre Colombia y Venezuela. Cuando Eduardo cotiza un envio, pone un transportista estimado (ej: "Transportes Pallares"), pero no sabe con certeza cual transportista usara hasta el dia anterior al envio. El sistema actual copia ese transportista al shipment y lo muestra como solo lectura — Eduardo no puede cambiarlo.
Esto se agrava con multiples envios por cotizacion: si cotizo con Pallares pero necesita 5 envios con 5 transportistas distintos, cada envio hereda "Pallares" y no puede cambiarlo.
Lo que vamos a construir: un paso obligatorio "Confirmar Transportista" antes de "Asignar Vehiculo", donde el usuario confirma o cambia el transportista y el costo del flete.
PENDING → TRUCK_ASSIGNED → PICKUP_ARRIVED → IN_TRANSIT → DELIVERED
PENDING → CARRIER_CONFIRMED → TRUCK_ASSIGNED → PICKUP_ARRIVED → IN_TRANSIT → DELIVERED
UI (React) → React Query Hook → Server Action → Service → Repository → Database
Cada capa tiene una responsabilidad:
- UI: formularios, botones, feedback visual
- React Query: cache, mutations, invalidacion
- Server Actions: validacion con Zod, autenticacion, delega al service
- Service: logica de negocio, transacciones, side effects
- Repository: queries SQL via Drizzle ORM
El sistema usa un framework llamado UAF (Unified Actions Framework). La "fuente de verdad" de cada workflow es un archivo *.workflow.definition.ts que define estados, eventos, transiciones y guards. La UI se genera automaticamente desde esa definicion.
Para agregar un nuevo paso al workflow, tocas estas capas en orden:
1. Constants (schema) → nuevo status string
2. Workflow Definition → nueva transicion
3. Zod Schema → validacion del input
4. Guards → reglas de negocio pre-transicion
5. Service → logica + side effects
6. Actions → entry point del servidor
7. UI Form → formulario del usuario
8. Form Registration → conectar form al workflow engine
9. Status Display → badge/chip visual
10. Derived Status Calc → como afecta al shipment padre
11. Process Graph → visualizacion del flujo
12. Tests → verificar todo
Cada una de estas capas ya tiene ejemplos funcionando (TRUCK_ASSIGNED, IN_TRANSIT, etc). Tu trabajo es seguir el patron exacto.
git checkout main && git pull
git checkout -b panchicore/feature-carrier-confirmed
pnpm installLee estos archivos para entender el patron existente (no los modifiques todavia, solo leelos):
src/db/schema/shipment-legs.ts → status constants
src/features/shipments/workflow/shipment-leg.workflow.definition.ts → workflow definition
src/features/shipments/lib/guards.ts → guard functions
src/features/shipments/lib/schemas.ts → Zod schemas
src/features/shipments/services/shipment-leg-service.ts → service layer
src/features/shipments/actions/shipment-leg-actions.ts → server actions
src/features/shipments/components/workflow/forms/ → transition forms (lee todos)
src/features/shipments/components/workflow/ShipmentLegCard.tsx → form registration
1.1 — Agregar status constant
Archivo: src/db/schema/shipment-legs.ts
Agrega CARRIER_CONFIRMED: 'CARRIER_CONFIRMED' al objeto SHIPMENT_LEG_STATUSES. No se necesita migracion de DB porque el campo status es text, no un enum de Postgres.
1.2 — Actualizar workflow definition
Archivo: src/features/shipments/workflow/shipment-leg.workflow.definition.ts
- Agrega
'carrier_confirmed'aSHIPMENT_LEG_STATES(entre'pending'y'truck_assigned') - Agrega
'CARRIER_CONFIRMED'aSHIPMENT_LEG_EVENTS(primera posicion) - Agrega nueva transicion al array
transitions(primera posicion):from: 'pending',event: 'CARRIER_CONFIRMED',to: 'carrier_confirmed'guard: 'canConfirmCarrier'icon: UserCheck(importar de lucide-react)title: 'Confirmar Transportista'description: 'Confirme o cambie el transportista y costo de flete para este tramo.'hasInlineForm: trueroles: ['OPERATIONS']agentMessage: funcion que muestre carrier actual, ruta y costo del flete (usa los helpersgetLegFreightCostygetSequencingNoteque ya existen)formId:(ctx) => \carrier-confirm-form-${ctx.leg.id}``
- Cambia la transicion de TRUCK_ASSIGNED:
from: 'pending'→from: 'carrier_confirmed' - Actualiza los
agentMessagede TODAS las transiciones: "Paso X de 4" → "Paso X de 5" (CARRIER_CONFIRMED es paso 1, TRUCK_ASSIGNED es paso 2, etc) - Agrega
carrier_confirmed: SHIPMENT_LEG_STATUSES.CARRIER_CONFIRMEDadbStatusCodes - Agrega
CARRIER_CONFIRMEDagetLegStatusInfo:{ label: 'Transp. Confirmado', description: 'Transportista confirmado para este tramo', isFinal: false }
1.3 — Agregar Zod schema
Archivo: src/features/shipments/lib/schemas.ts
export const executeShipmentLegTransitionCarrierConfirmedSchema = z.object({
carrierPartyId: z.string().uuid('Seleccione un transportista'),
estimatedFreightCost: z.string().regex(/^\d+(\.\d{1,2})?$/, 'Formato de costo inválido'),
});
export type ConfirmCarrierInput = z.infer<typeof executeShipmentLegTransitionCarrierConfirmedSchema>;1.4 — Agregar guard
Archivo: src/features/shipments/lib/guards.ts
- Agrega
estimatedFreightCost?: stringa la interfaceLegEventInput - Agrega funcion
canConfirmCarrier(valida quecarrierPartyIdeste presente). Sigue el patron exacto decanAssignTruck - En
validateLegTransition:- Agrega caso:
event === 'CARRIER_CONFIRMED' && statusCode === SHIPMENT_LEG_STATUSES.PENDING→canConfirmCarrier - Cambia el caso de TRUCK_ASSIGNED:
statusCode === SHIPMENT_LEG_STATUSES.PENDING→statusCode === SHIPMENT_LEG_STATUSES.CARRIER_CONFIRMED
- Agrega caso:
1.5 — Agregar metodo en service
Archivo: src/features/shipments/services/shipment-leg-service.ts
Agrega metodo publico executeShipmentLegTransitionCarrierConfirmed. Este tiene un side effect que actualiza el cost item de FREIGHT.
CRITICO: El side effect DEBE ejecutarse dentro de la MISMA transaccion que la transicion del leg. Mira como
IN_TRANSITlo hace con el anticipo enhandleAdvancePaymentSideEffect— ese es tu modelo exacto. NO usesShipmentCostItemService.updateShipmentCostItem()porque ese metodo abre su propia transaccion interna. Debes usar los repos directamente dentro delrunTransaction().
Pseudocodigo del metodo (sigue esta estructura exacta):
async executeShipmentLegTransitionCarrierConfirmed(
organizationId: string,
legId: string,
input: ConfirmCarrierInput,
userId: string,
): Promise<ExecuteLegTransitionResultResponse> {
// 1. Preparar inputs para el core (igual que los otros metodos)
const legEventInput: LegEventInput = {
carrierPartyId: input.carrierPartyId,
};
const legStatusUpdate: Partial<InsertShipmentLeg> = {
carrierPartyId: input.carrierPartyId,
};
// 2. Ejecutar el core — PERO necesitas el side effect transaccional
// Sigue el patron de IN_TRANSIT (lineas 249-283 del service):
//
// a) Carga el leg, valida topologia, corre guards (todo esto lo hace executeTransitionCore)
// b) PERO en vez de delegar a executeTransitionCore directamente,
// necesitas wrappear en runTransaction para incluir el side effect.
//
// La forma mas limpia: agrega el side effect DESPUES de executeTransitionCore
// dentro de una transaccion, igual que IN_TRANSIT.
// Mira como IN_TRANSIT hace el if (eventType === 'IN_TRANSIT') en executeTransitionCore
// y agrega tu caso: if (eventType === 'CARRIER_CONFIRMED')
// 3. Dentro del runTransaction:
// a) this.legRepo.executeShipmentLegTransition({...}, tx)
// b) this.handleCarrierConfirmationSideEffect(orgId, legId, shipmentId, input, userId, tx)
// 4. El side effect (metodo privado nuevo):
// a) const costItems = await this.costItemRepo.findShipmentCostItemsByLegId(orgId, legId, tx)
// b) const freightItem = costItems.find(ci => ci.chargeTypeCode === 'FREIGHT')
// c) if (freightItem) {
// const derived = computeCostDerivedFields(
// input.estimatedFreightCost,
// freightItem.customerUnitPrice,
// freightItem.quantity
// );
// await this.costItemRepo.updateShipmentCostItem(orgId, freightItem.id, {
// vendorPartyId: input.carrierPartyId,
// estimatedCost: input.estimatedFreightCost,
// ...derived,
// }, userId, tx);
// await this.costItemRepo.recalculateShipmentTotals(orgId, shipmentId, userId, tx);
// }
}Importaciones que necesitas: ConfirmCarrierInput del schema, computeCostDerivedFields de @/features/shipments/lib/cost-calculations.
1.6 — Agregar case en actions
Archivo: src/features/shipments/actions/shipment-leg-actions.ts
Agrega case 'CARRIER_CONFIRMED': en el switch. Sigue el patron exacto de los otros cases (parse → service call → return success).
Checkpoint: despues de esto, corre tsc --noEmit para verificar tipos. No deberia haber errores.
2.1 — Crear formulario
Archivo nuevo: src/features/shipments/components/workflow/forms/shipment-leg.carrier-confirmed.transition-form.tsx
Sigue el patron de StartTransitForm (que tambien recibe costItems como prop):
- Props:
{ leg: ShipmentLegWithLocationsResponse, costItems: ShipmentCostItemWithVendorResponse[], onConfirm: (data: ConfirmCarrierInput) => void, formId?: string } - Carrier selector: reutiliza
PartySelectorde@/shared/components/selectors/PartySelector+useVendorPartiesForLineItems()de@/features/quotations/api/lookups-api. Pre-llenado conleg.carrierPartyId. Filtra client-side para mostrar solo parties conpartyTypeCode === 'CARRIER'NO crees un hook nuevo tipo
useCarrierParties. El hookuseVendorPartiesForLineItems()ya trae carriers, fixers y service providers. Filtra en el componente:parties.filter(p => p.partyTypeCode === 'CARRIER'). Crear hooks nuevos rompe DRY y el PR sera rechazado. - Freight cost input: reutiliza
CurrencyInputde@/shared/components/form/CurrencyInput. Pre-llenado desde el cost item FREIGHT del leg:costItems.find(ci => ci.shipmentLegId === leg.id && ci.chargeTypeCode === 'FREIGHT')?.estimatedCost - AgentMessage: muestra carrier actual y costo, explica que puede confirmar o cambiar
- handleSubmit: construye
ConfirmCarrierInputy llamaonConfirm(data)
2.2 — Registrar formulario
Archivo: src/features/shipments/components/workflow/ShipmentLegCard.tsx
- Importa
CarrierConfirmationFormyConfirmCarrierInput - Agrega
case 'CARRIER_CONFIRMED':en el switch derenderForm— pasaleg,costItems,onConfirm,formId
2.3 — Agregar badge de status
Archivo: src/features/shipments/components/ShipmentDerivedStatusChip.tsx
Agrega a LEG_STATUS_COMPACT:
CARRIER_CONFIRMED: { label: 'Transp. confirmado', className: 'bg-cyan-100 text-cyan-700' },2.4 — Actualizar derived status calculator
Archivo: src/features/shipments/lib/shipment-status-calculator.ts
Agrega CARRIER_CONFIRMED a la funcion isInProgress (linea 70-73):
status === SHIPMENT_LEG_STATUSES.CARRIER_CONFIRMED ||2.5 — Actualizar process graph
Archivo: src/features/shipments/lib/shipment-process-graph.ts
- Agrega
CARRIER_CONFIRMED: 'carrier_confirmed'adbStatusToStateKey - Agrega
carrier_confirmed: 'CARRIER_CONFIRMED'agetSpanishLabel
Checkpoint: corre tsc --noEmit && pnpm lint — sin errores.
Actualiza los tests existentes y agrega nuevos. Los archivos de test estan en tests/unit/workflow/ y tests/unit/services/. Lee los tests existentes primero para entender la estructura.
Cambios esperados:
- Workflow topology: counts de estados/eventos cambian (5→6 estados, 4→5 eventos)
- Transiciones: PENDING ya no va a TRUCK_ASSIGNED, va a CARRIER_CONFIRMED
- Guards: nuevo test suite para
canConfirmCarrier - Calculator:
CARRIER_CONFIRMEDcuenta como "in progress" - Process graph: node/edge counts cambian
- Service: test para
executeShipmentLegTransitionCarrierConfirmed(happy path, cambio de carrier, sin FREIGHT item)
pnpm test:runJuan Miguel: esta fase es donde demuestras que la feature funciona. No es opcional. Cada item debe quedar verificado antes de pedir PR review.
pnpm qa # lint + type-check + unit tests (todo debe pasar)Levanta el dev server:
pnpm dev- Ve a una cotizacion existente con transportista asignado
- Acepta la cotizacion y crea un envio
- Ve al detalle del envio → tab Acciones
- Verifica: la primera accion disponible es "Confirmar Transportista" (NO "Asignar Vehiculo")
- Abre el formulario — verifica que el carrier y costo estan pre-llenados
- Confirma sin cambiar nada
- Verifica: el leg pasa a estado
CARRIER_CONFIRMED - Verifica: la siguiente accion es "Asignar Vehiculo"
- Completa el flujo: asigna vehiculo → llegada → transito → entrega
- Crea otro envio desde la misma cotizacion
- En "Confirmar Transportista", cambia el carrier a uno diferente
- Cambia tambien el costo del flete
- Confirma
- Verifica en tab Cargos: el cost item FREIGHT tiene el nuevo carrier como vendor
- Verifica en tab Cargos: el monto del flete cambio al que ingresaste
- Verifica: los totales del envio (costo interno, precio cliente, margen) se recalcularon
- Ve a "Asignar Vehiculo" — verifica que los conductores y vehiculos son del NUEVO carrier
- Desde una cotizacion con carrier "Transportes A", crea envio 1
- Confirma transportista con "Transportes A" (sin cambio)
- Crea envio 2 desde la misma cotizacion
- Confirma transportista con "Transportes B" (cambio)
- Verifica: envio 1 tiene carrier A, envio 2 tiene carrier B
- Verifica: los costos de flete son independientes entre envios
Usa Claude Code para verificar directamente en DB. Pide a Claude que ejecute queries contra Supabase para confirmar:
- shipment_legs.carrier_party_id se actualizo al carrier confirmado
- shipment_legs.status = 'CARRIER_CONFIRMED'
- shipment_cost_items WHERE charge_type_code = 'FREIGHT': vendor_party_id = carrier confirmado
- shipment_cost_items WHERE charge_type_code = 'FREIGHT': estimated_cost = monto ingresado
- shipments: total_internal_cost, total_customer_price, total_profit recalculados
- workflow_events: existe registro con event_type = 'CARRIER_CONFIRMED', from_status = 'PENDING', to_status = 'CARRIER_CONFIRMED'
- Cotizaciones: crear, editar, enviar, aceptar — todo sigue funcionando
- Envios: el flujo completo CARRIER_CONFIRMED → TRUCK_ASSIGNED → PICKUP → TRANSIT → DELIVERED funciona
- Process graph: la visualizacion muestra 6 nodos por tramo (incluye carrier_confirmed)
- Anticipo: al iniciar transito, el calculo de anticipo sigue funcionando con el carrier confirmado
[ ] pnpm qa pasa limpio
[ ] Escenario 1 verificado (confirmar sin cambios)
[ ] Escenario 2 verificado (cambiar carrier + costo)
[ ] Escenario 3 verificado (multiples envios, diferentes carriers)
[ ] Datos verificados en DB
[ ] No-regresion verificada
[ ] Commit con mensaje descriptivo
[ ] Push al branch
[ ] PR creado apuntando a main
- Sigue los patrones existentes. Si ves algo que no sabes como hacer, busca como lo hace TRUCK_ASSIGNED o IN_TRANSIT. No inventes.
- No crees abstracciones nuevas. Reutiliza PartySelector, CurrencyInput, AgentMessage, computeCostDerivedFields. Todo ya existe.
- Corre
tsc --noEmitfrecuentemente. Despues de cada fase, no solo al final. - Si te atascas, lee el archivo que sigue el patron que necesitas. Claude Code puede ayudarte a encontrarlo.
- No hagas PR hasta completar la Fase 4 completa. El PR review sera rapido si la verificacion esta hecha.
src/db/schema/shipment-legs.ts
src/features/shipments/workflow/shipment-leg.workflow.definition.ts
src/features/shipments/lib/schemas.ts
src/features/shipments/lib/guards.ts
src/features/shipments/services/shipment-leg-service.ts
src/features/shipments/actions/shipment-leg-actions.ts
src/features/shipments/components/workflow/ShipmentLegCard.tsx
src/features/shipments/components/ShipmentDerivedStatusChip.tsx
src/features/shipments/lib/shipment-status-calculator.ts
src/features/shipments/lib/shipment-process-graph.ts
tests/unit/workflow/*.test.ts (varios)
src/features/shipments/components/workflow/forms/shipment-leg.carrier-confirmed.transition-form.tsx
- Crea el PR apuntando a
main - En la descripcion del PR incluye screenshots de:
- El formulario de "Confirmar Transportista" pre-llenado
- El formulario con un carrier diferente seleccionado
- El tab Cargos mostrando el vendor actualizado
- Avisa a Luis para PR review