Solicitud de servicio:
- Campos del documento maestro de cotización (ruta estructurada por país,
mercancía, dimensiones/bultos, FCL/LCL, servicios adicionales, notas).
- Origen/Destino seleccionables por catálogo de país (seed ya poblado).
- Validación de contacto asociado (422 si no existe).
Ciclo Oportunidad -> Solicitud -> Cotización -> Operación:
- Dirección impo/expo se captura en la Oportunidad y se hereda al ciclo.
- Conversión Oportunidad->Solicitud idempotente con back-link.
- Endpoint Solicitud->Cotización; "Ambas" genera 2 cotizaciones (FCL/LCL).
- Liberación a Operaciones confirma IMPO/EXPO (prefijado) y siembra los hitos.
- Fecha de la cotización (issue_date) por defecto hoy, editable y en el PDF.
Folios auto-generados {LETRA}{AAAA}-{MM}-{NNN}-{DIR} para Oportunidad (O),
Solicitud (S), Cotización (C) y Operación (OP); consecutivo mensual por
compañía y entidad (crm.folio_counters + helper next_folio con bloqueo de fila).
Catálogos: 9 nuevos (tipo_operacion, medio_transporte, tipo_servicio, prioridad,
tipo_mercancia, unidad_medida, tipo_embalaje, servicio_adicional, tipo_documento).
Migración b1c2d3e4f5a6 reversible (upgrade->downgrade->upgrade verificado en PG).
25 pruebas unitarias nuevas (folios, catálogos, solicitudes, cotizaciones,
embarques); suite completa en verde (101 pruebas).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
6.1 KiB
TypeScript
139 lines
6.1 KiB
TypeScript
/**
|
|
* Cliente API — Operaciones (Embarques y documentos de transporte).
|
|
*/
|
|
import { api } from '$lib/api';
|
|
|
|
export type ShipmentStatus =
|
|
| 'abierta' | 'booking' | 'en_transito' | 'arribado' | 'entregada' | 'cerrada' | 'cancelada';
|
|
|
|
export interface Shipment {
|
|
id: number;
|
|
reference: string | null;
|
|
quote_id: number | null;
|
|
service_request_id: number | null;
|
|
account_id: number | null;
|
|
operation_type: string | null;
|
|
transport_mode: string | null;
|
|
service_type: string | null;
|
|
incoterm: string | null;
|
|
origin: string | null;
|
|
destination: string | null;
|
|
status: ShipmentStatus;
|
|
booking_number: string | null;
|
|
carrier_supplier_id: number | null;
|
|
ground_carrier_supplier_id: number | null;
|
|
customs_agent_id: number | null;
|
|
destination_agent_id: number | null;
|
|
cutoff_date: string | null;
|
|
pickup_at: string | null;
|
|
etd: string | null;
|
|
previous_etd: string | null;
|
|
eta: string | null;
|
|
vessel_flight: string | null;
|
|
container_number: string | null;
|
|
notes: string | null;
|
|
actual_cost_total: number | null;
|
|
cost_currency: string | null;
|
|
closed_at: string | null;
|
|
closed_by: string | null;
|
|
owner_user_id: string | null;
|
|
created_by: string | null;
|
|
updated_by: string | null;
|
|
tenant_id: number;
|
|
company_id: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
export type ShipmentInput = Partial<Omit<Shipment, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by'>>;
|
|
|
|
export interface ShipmentEvent {
|
|
id: number;
|
|
shipment_id: number;
|
|
event_type: string | null;
|
|
title: string;
|
|
kind: string; // hito | decision
|
|
status: string; // pendiente | completado | omitido | rechazado | en_correccion
|
|
outcome: string | null; // autorizado | rechazado
|
|
parent_event_id: number | null;
|
|
attempt: number;
|
|
position: number;
|
|
planned_date: string | null;
|
|
actual_date: string | null;
|
|
notes: string | null;
|
|
tenant_id: number;
|
|
company_id: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
export type ShipmentEventInput = Partial<Omit<ShipmentEvent, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
|
|
shipment_id: number;
|
|
title: string;
|
|
};
|
|
|
|
export interface ShipmentDocument {
|
|
id: number;
|
|
shipment_id: number;
|
|
doc_kind: string;
|
|
doc_type: string;
|
|
number: string | null;
|
|
issue_date: string | null;
|
|
file_url: string | null;
|
|
file_key: string | null;
|
|
notes: string | null;
|
|
tenant_id: number;
|
|
company_id: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
export type ShipmentDocumentInput = Partial<Omit<ShipmentDocument, 'id' | 'tenant_id' | 'company_id' | 'created_at' | 'updated_at'>> & {
|
|
shipment_id: number;
|
|
doc_type: string;
|
|
};
|
|
|
|
function qp(companyId: number, extra?: Record<string, string | number | undefined>) {
|
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
|
for (const [k, v] of Object.entries(extra ?? {})) if (v !== undefined && v !== '') qs.set(k, String(v));
|
|
return qs.toString();
|
|
}
|
|
async function unwrap<T>(p: Promise<{ data?: T; error?: string }>): Promise<T> {
|
|
const res = await p;
|
|
if (res.error) throw new Error(res.error);
|
|
return res.data as T;
|
|
}
|
|
|
|
export const shipmentsAPI = {
|
|
list: (companyId: number, params?: { search?: string; status?: string; account_id?: number }) =>
|
|
unwrap<Shipment[]>(api.get(`/v1/ops/shipments?${qp(companyId, params)}`)),
|
|
get: (id: number, companyId: number) => unwrap<Shipment>(api.get(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
|
|
create: (data: ShipmentInput, companyId: number) => unwrap<Shipment>(api.post(`/v1/ops/shipments?${qp(companyId)}`, data)),
|
|
createFromQuote: (quoteId: number, companyId: number, operationType?: string) =>
|
|
unwrap<Shipment>(api.post(`/v1/ops/shipments/from-quote?${qp(companyId, { quote_id: quoteId, operation_type: operationType })}`, {})),
|
|
update: (id: number, data: Partial<ShipmentInput>, companyId: number) => unwrap<Shipment>(api.patch(`/v1/ops/shipments/${id}?${qp(companyId)}`, data)),
|
|
reschedule: (id: number, data: { etd?: string | null; cutoff_date?: string | null; reason?: string | null }, companyId: number) =>
|
|
unwrap<Shipment>(api.post(`/v1/ops/shipments/${id}/reschedule?${qp(companyId)}`, data)),
|
|
close: (id: number, data: { actual_cost_total: number; cost_currency?: string; notes?: string | null }, companyId: number) =>
|
|
unwrap<Shipment>(api.post(`/v1/ops/shipments/${id}/close?${qp(companyId)}`, data)),
|
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipments/${id}?${qp(companyId)}`)),
|
|
documents: (shipmentId: number, companyId: number) =>
|
|
unwrap<ShipmentDocument[]>(api.get(`/v1/ops/shipments/${shipmentId}/documents?${qp(companyId)}`)),
|
|
events: (shipmentId: number, companyId: number) =>
|
|
unwrap<ShipmentEvent[]>(api.get(`/v1/ops/shipments/${shipmentId}/events?${qp(companyId)}`)),
|
|
seedEvents: (shipmentId: number, companyId: number) =>
|
|
unwrap<ShipmentEvent[]>(api.post(`/v1/ops/shipments/${shipmentId}/events/seed?${qp(companyId)}`, {}))
|
|
};
|
|
|
|
export const shipmentEventsAPI = {
|
|
create: (data: ShipmentEventInput, companyId: number) => unwrap<ShipmentEvent>(api.post(`/v1/ops/shipment-events?${qp(companyId)}`, data)),
|
|
update: (id: number, data: Partial<ShipmentEventInput>, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}?${qp(companyId)}`, data)),
|
|
complete: (id: number, companyId: number) => unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}/complete?${qp(companyId)}`, {})),
|
|
decide: (id: number, outcome: 'autorizado' | 'rechazado', companyId: number, notes?: string | null) =>
|
|
unwrap<ShipmentEvent>(api.patch(`/v1/ops/shipment-events/${id}/decision?${qp(companyId)}`, { outcome, notes })),
|
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-events/${id}?${qp(companyId)}`))
|
|
};
|
|
|
|
export const shipmentDocumentsAPI = {
|
|
create: (data: ShipmentDocumentInput, companyId: number) => unwrap<ShipmentDocument>(api.post(`/v1/ops/shipment-documents?${qp(companyId)}`, data)),
|
|
update: (id: number, data: Partial<ShipmentDocumentInput>, companyId: number) => unwrap<ShipmentDocument>(api.patch(`/v1/ops/shipment-documents/${id}?${qp(companyId)}`, data)),
|
|
remove: (id: number, companyId: number) => unwrap(api.delete(`/v1/ops/shipment-documents/${id}?${qp(companyId)}`))
|
|
};
|