Files
edr-platform/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
Marshal dc38e843a6 feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
2026-08-15 10:12:37 +00:00

280 lines
11 KiB
TypeScript

import type { Freight, PaginatedResponse } from "@edr/types";
import { api as apiClient } from "../auth/http";
export interface Wagon {
id: string;
wagonNumber: string;
wagonTypeId: string;
trainId: string | null;
sequenceNumber: number | null;
currentLocationYardId: string | null;
currentLocationYard?: {
id: string;
code: string;
label: string;
country?: string;
} | null;
/** Owns this wagon's spec — tare, capacity, length are read from here, never off the wagon. */
wagonType?: {
id: string;
code: string;
name: string;
supportedLoadTypes?: string[];
tareWeightTons?: number;
capacityTons?: number;
lengthMeters?: number;
} | null;
status: Freight.WagonStatus;
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
lastMaintenanceAt?: string | null;
lastAvailableAt?: string | null;
currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */
exportTrainNumber?: string | null;
/** Even IMPORT run (Djibouti → Ethiopia); always the export run + 1. */
importTrainNumber?: string | null;
notes?: string;
}
export interface WagonListFilters {
search?: string;
status?: Freight.WagonStatus;
currentYardId?: string;
wagonTypeId?: string;
trainId?: string;
/** Drop wagons already coupled to a built train — only loose ones can be taken. */
unassigned?: boolean;
/** Run number — matches a wagon whose export OR import run equals it. */
trainNumber?: string;
/** Registration day range (YYYY-MM-DD), both ends inclusive. */
createdFrom?: string;
createdTo?: string;
/** Only read by `getPaged`. */
page?: number;
pageSize?: number;
}
const wagonListQuery = (filters: WagonListFilters): string => {
const params = new URLSearchParams();
if (filters.search?.trim()) params.set('search', filters.search.trim());
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.wagonTypeId) params.set('wagonTypeId', filters.wagonTypeId);
if (filters.trainId) params.set('trainId', filters.trainId);
if (filters.unassigned) params.set('unassigned', 'true');
if (filters.trainNumber) params.set('trainNumber', filters.trainNumber);
if (filters.createdFrom) params.set('createdFrom', filters.createdFrom);
if (filters.createdTo) params.set('createdTo', filters.createdTo);
if (filters.page) params.set('page', String(filters.page));
if (filters.pageSize) params.set('pageSize', String(filters.pageSize));
const qs = params.toString();
return qs ? `?${qs}` : '';
};
/**
* One row of the wagon_movements ledger: every physical relocation between
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
* correction. Returned newest first by the API.
*/
export interface WagonMovementRecord {
id: string;
wagonId: string;
fromYardId: string | null;
toYardId: string;
fromYard?: { id?: string; label?: string; code?: string } | null;
toYard?: { id?: string; label?: string; code?: string } | null;
trainScheduleId: string | null;
bookingId: string | null;
kind: Freight.WagonMovementKind;
movedByUserId: string | null;
/** The transfer request this move fulfilled, when one drove it. */
transferRequestId: string | null;
occurredAt: string;
note: string | null;
createdAt: string;
wagon?: { id: string; wagonNumber?: string } | null;
}
/** One row of the wagon status audit trail. Returned newest first by the API. */
export interface WagonStatusLog {
id: string;
wagonId: string;
fromStatus: Freight.WagonStatus;
toStatus: Freight.WagonStatus;
changedByUserId: string | null;
note: string | null;
createdAt: string;
}
export const wagonService = {
/** One page ({items, meta}); 10 rows unless `pageSize` says otherwise. */
getAll: (filters: WagonListFilters = {}) =>
apiClient.get<PaginatedResponse<Wagon>>(`/wagons${wagonListQuery(filters)}`),
/**
* Every matching wagon, page-walked at the API's 100-row cap. For the pickers
* and yard views that filter the whole fleet in the browser — a list page
* should use `getAll` and show the real page controls instead.
*/
listAll: async (filters: WagonListFilters = {}): Promise<Wagon[]> => {
const pageSize = 100;
const first = await wagonService.getAll({ ...filters, page: 1, pageSize });
const items = [...first.data.items];
for (let page = 2; page <= (first.data.meta.totalPages ?? 1); page += 1) {
const next = await wagonService.getAll({ ...filters, page, pageSize });
items.push(...next.data.items);
}
return items;
},
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getMovements: (id: string) =>
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),
getByTrain: (trainId: string) => wagonService.listAll({ trainId }),
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
/** Irreversible purge — the API refuses it when the wagon has any history. */
purge: (id: string) => apiClient.delete(`/wagons/${id}/permanent`),
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
bulkTransfer: (wagonIds: string[], toYardId: string) =>
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
/** Set the same status on many wagons in a single call (writes audit rows). */
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus, note?: string) =>
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status, note }),
/** Status audit trail for one wagon, newest first. */
getStatusHistory: (id: string) =>
apiClient.get<WagonStatusLog[]>(`/wagons/${id}/status-history`),
};
/**
* A two-person wagon-transfer request: a requester asks for N wagons of a type
* to move between yards (count only); OCC hand-picks the wagons and fulfils it.
*/
export interface WagonTransferRequest {
id: string;
fromYardId: string;
toYardId: string;
wagonTypeId: string;
quantity: number;
/** How many have actually moved so far — OCC delivers in instalments. */
fulfilledQuantity: number;
status: Freight.WagonTransferRequestStatus;
requestedByUserId: string | null;
fulfilledByUserId: string | null;
/** When the LAST instalment ran, not necessarily the full count. */
fulfilledAt: string | null;
closedShortAt?: string | null;
closedShortByUserId?: string | null;
/** Why the wagons are needed — required for new requests, shown on the queue. */
reason?: string | null;
/** The wagons the requester hand-picked, if any. A preference, not a reservation. */
preferredWagonIds?: string[] | null;
/** Those same picks resolved to wagon numbers by the API, for display. */
preferredWagons?: Array<{ id: string; wagonNumber: string }>;
note: string | null;
fromYard?: { id: string; label?: string; code?: string } | null;
toYard?: { id: string; label?: string; code?: string } | null;
wagonType?: { id: string; code?: string; name?: string } | null;
createdAt: string;
}
export interface CreateTransferRequestPayload {
fromYardId: string;
toYardId: string;
wagonTypeId: string;
quantity: number;
/** Mandatory: why the wagons are needed. */
reason: string;
/** Specific wagons the requester wants — at most `quantity` of them. */
preferredWagonIds?: string[];
note?: string;
}
/** Bulk accept-and-execute result: what ran, what stayed open and why. */
export interface BulkFulfillResult {
fulfilled: WagonTransferRequest[];
skipped: Array<{ id: string; reason: string }>;
}
/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
export interface TransferHistory {
requests: WagonTransferRequest[];
movements: WagonMovementRecord[];
meta: {
page: number;
pageSize: number;
requestsTotal: number;
movementsTotal: number;
totalPages: number;
};
}
/** Desk list filters — `status` may be a comma-separated set ("open" tab). */
export interface TransferRequestListFilter {
status?: string;
fromYardId?: string;
toYardId?: string;
wagonTypeId?: string;
search?: string;
page?: number;
pageSize?: number;
}
const listQuery = (filter: TransferRequestListFilter = {}): string => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) {
if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value));
}
}
const qs = params.toString();
return qs ? `?${qs}` : '';
};
export const wagonTransferRequestService = {
list: (filter: TransferRequestListFilter = {}) =>
apiClient.get<PaginatedResponse<WagonTransferRequest>>(
`/wagon-transfer-requests${listQuery(filter)}`,
),
/** The caller's own history (both roles: requests they filed and fulfilled). */
myHistory: (page = 1, pageSize = 20) =>
apiClient.get<TransferHistory>(
`/wagon-transfer-requests/history?page=${page}&pageSize=${pageSize}`,
),
/** Admin: any/all staff's history (optional userId filter). */
allHistory: (userId?: string, page = 1, pageSize = 20) =>
apiClient.get<TransferHistory>(
`/wagon-transfer-requests/history/all?page=${page}&pageSize=${pageSize}` +
`${userId ? `&userId=${userId}` : ''}`,
),
getById: (id: string) =>
apiClient.get<WagonTransferRequest>(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>
apiClient.post<WagonTransferRequest>('/wagon-transfer-requests', data),
/** OCC: accept-and-execute a subset of pending requests (auto-picked wagons). */
bulkFulfill: (requestIds: string[]) =>
apiClient.post<BulkFulfillResult>('/wagon-transfer-requests/bulk-fulfill', {
requestIds,
}),
/** OCC: execute the transfer with the hand-picked wagons. */
fulfill: (id: string, wagonIds: string[]) =>
apiClient.post<WagonTransferRequest>(
`/wagon-transfer-requests/${id}/fulfill`,
{ wagonIds },
),
cancel: (id: string) =>
apiClient.post<WagonTransferRequest>(
`/wagon-transfer-requests/${id}/cancel`,
),
/** OCC: end the request short — the source yard has no more to give. */
closeShort: (id: string, note?: string) =>
apiClient.post<WagonTransferRequest>(
`/wagon-transfer-requests/${id}/close-short`,
{ note },
),
};