mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Export cargo reaches a train two ways, but the platform only modelled one. Direct truck-to-train cargo loads straight onto the wagon, never enters a warehouse and so never has a GRN — yet assertExportReceivedWithGrn required one before the carriage acceptance sheet could be issued or the booking loaded from inside its schedule. Adds export_handover_mode to freight.bookings (null = WAREHOUSE, so existing bookings are unaffected) and teaches the shared gate to skip DIRECT_TO_TRAIN. Both call sites are fixed by that single early return. For direct bookings the carriage acceptance sheet builds its lines from the booking's own containers, falling back to the declared bulk tonnage, and is issuable as soon as the mode is chosen. Direct bookings are also removed from the warehouse receive queue, since that cargo is never coming to the shed. Staff choose the mode from the booking detail page via a new endpoint reusing bookings:operations. Switching to direct is refused once warehouse inventory exists, so the two flows cannot cross. Warehouse-then-train keeps every gate it had.
488 lines
17 KiB
TypeScript
488 lines
17 KiB
TypeScript
import { api as client } from "../auth/http";
|
|
import { unwrap } from "@/utils/endpoint";
|
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
|
import type { BookingDetail } from "@/types/booking";
|
|
import type { Freight } from "@edr/types";
|
|
|
|
const B = URL_CONSTANTS.BOOKINGS;
|
|
|
|
export interface BookingListFilter {
|
|
status?: string;
|
|
/** Comma-separated statuses for grouped tabs */
|
|
statuses?: string;
|
|
schedulingStatuses?: string;
|
|
assignedToSchedule?: "true" | "false";
|
|
/** Tab key for React Query cache (not sent to API) */
|
|
tab?: string;
|
|
// customerId?: string;
|
|
companyId?: string;
|
|
freightType?: string;
|
|
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
|
|
bookingType?: string;
|
|
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
|
|
customsClearingEnabled?: "true" | "false";
|
|
tradeDirection?: string;
|
|
paymentCurrency?: string;
|
|
paymentStatus?: string;
|
|
/** ISO date-time — bookings created on/after. */
|
|
createdFrom?: string;
|
|
/** ISO date-time — bookings created on/before (pass end-of-day for inclusive). */
|
|
createdTo?: string;
|
|
/** ISO date-time — bookings scheduled on/after. */
|
|
scheduledFrom?: string;
|
|
/** ISO date-time — bookings scheduled on/before (pass end-of-day for inclusive). */
|
|
scheduledTo?: string;
|
|
originYardId?: string;
|
|
destinationYardId?: string;
|
|
/** "true" = government bookings only, "false" = private only. */
|
|
isGovernment?: "true" | "false";
|
|
/** Free-text search: booking reference, customer name, contract reference (server-side). */
|
|
search?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: string;
|
|
sortOrder?: "ASC" | "DESC";
|
|
}
|
|
|
|
export interface PaginatedBookings {
|
|
items: BookingDetail[];
|
|
total: number;
|
|
}
|
|
|
|
export interface BookingListSummaryMetrics {
|
|
inQueue: number;
|
|
onThisPage: number;
|
|
needsAction: number;
|
|
urgent: number;
|
|
}
|
|
|
|
export interface BookingListSummaryTabs {
|
|
all: number;
|
|
intake: number;
|
|
in_approval: number;
|
|
approved_contract: number;
|
|
payment: number;
|
|
operations: number;
|
|
completed: number;
|
|
closed: number;
|
|
}
|
|
|
|
export interface BookingListSummary {
|
|
metrics: BookingListSummaryMetrics;
|
|
tabs: BookingListSummaryTabs;
|
|
}
|
|
|
|
export interface ContractView {
|
|
bookingId: string;
|
|
reference: string;
|
|
status: string;
|
|
templateKey: string;
|
|
title: string;
|
|
html: string;
|
|
canSignCustomer: boolean;
|
|
canSignStaff: boolean;
|
|
hasContractDocument: boolean;
|
|
signatures: Array<{
|
|
role: string;
|
|
signerDisplayName: string;
|
|
signedAt: string;
|
|
signatureImageUrl?: string | null;
|
|
}>;
|
|
/** Current viewer's reusable saved signature, if they have one. */
|
|
savedSignature?: {
|
|
signerDisplayName: string;
|
|
signatureImageUrl?: string | null;
|
|
stampImageUrl?: string | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface ConsolidationWagonSlot {
|
|
containerTypeCode: string;
|
|
quantity: number;
|
|
containersPerWagon: number;
|
|
remainder: number;
|
|
slotsNeeded: number;
|
|
}
|
|
|
|
export interface ConsolidationDetails {
|
|
statusMessage: string;
|
|
wagonSlots: ConsolidationWagonSlot[];
|
|
partner: { id: string; reference: string } | null;
|
|
splitBilling: { bookingShare: number; partnerShare: number } | null;
|
|
}
|
|
|
|
export interface SignContractPayload {
|
|
role: "CUSTOMER" | "STAFF";
|
|
signatureImageBase64: string;
|
|
/** Company stamp/seal image; the API requires one for CUSTOMER and STAFF. */
|
|
stampImageBase64?: string;
|
|
signerDisplayName: string;
|
|
consentText?: string;
|
|
}
|
|
|
|
async function postBooking<T>(url: string, body?: unknown): Promise<T> {
|
|
const response = await client.post<T>(url, body ?? {});
|
|
return unwrap(response.data);
|
|
}
|
|
|
|
export const bookingsService = {
|
|
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
|
|
const params: Record<string, string | number | boolean | undefined> = {};
|
|
if (filter) {
|
|
if (filter.statuses) params.statuses = filter.statuses;
|
|
else if (filter.status) params.status = filter.status;
|
|
if (filter.page != null) params.page = filter.page;
|
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
|
if (filter.companyId) params.companyId = filter.companyId;
|
|
if (filter.freightType) params.freightType = filter.freightType;
|
|
if (filter.bookingType) params.bookingType = filter.bookingType;
|
|
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
|
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
|
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
|
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
|
if (filter.createdTo) params.createdTo = filter.createdTo;
|
|
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
|
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
|
if (filter.originYardId) params.originYardId = filter.originYardId;
|
|
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
|
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
|
}
|
|
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
|
|
params,
|
|
});
|
|
return unwrap(response.data) as BookingListSummary;
|
|
},
|
|
|
|
list: async (filter?: BookingListFilter): Promise<PaginatedBookings> => {
|
|
const params: Record<string, string | number | boolean | undefined> = {};
|
|
if (filter) {
|
|
if (filter.statuses) params.statuses = filter.statuses;
|
|
else if (filter.status) params.status = filter.status;
|
|
// filter.tab is intentionally omitted from API params
|
|
if (filter.page != null) params.page = filter.page;
|
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
|
if (filter.sortBy) params.sortBy = filter.sortBy;
|
|
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
|
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
|
|
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
|
|
if (filter.companyId) params.companyId = filter.companyId;
|
|
if (filter.freightType) params.freightType = filter.freightType;
|
|
if (filter.bookingType) params.bookingType = filter.bookingType;
|
|
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
|
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
|
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
|
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
|
if (filter.createdTo) params.createdTo = filter.createdTo;
|
|
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
|
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
|
if (filter.originYardId) params.originYardId = filter.originYardId;
|
|
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
|
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
|
if (filter.customsClearingEnabled)
|
|
params.customsClearingEnabled = filter.customsClearingEnabled;
|
|
if (filter.search) params.search = filter.search;
|
|
}
|
|
const response = await client.get<PaginatedBookings>(B.BASE, {
|
|
params,
|
|
});
|
|
const data = unwrap(response.data);
|
|
return {
|
|
items: (data.items ?? []) as BookingDetail[],
|
|
total: data.total ?? 0,
|
|
};
|
|
},
|
|
|
|
getById: async (id: string): Promise<BookingDetail> => {
|
|
const response = await client.get<BookingDetail>(B.BY_ID(id));
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
remove: async (id: string): Promise<void> => {
|
|
await client.delete(B.BY_ID(id));
|
|
},
|
|
|
|
// ── Document clearance (GL workflow) ──
|
|
reviewClearanceDocument: (
|
|
id: string,
|
|
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
|
|
) => postBooking<BookingDetail>(`/bookings/${id}/clearance/review`, payload),
|
|
|
|
uploadClearanceOutput: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(
|
|
`/bookings/${id}/clearance/output-documents`,
|
|
form,
|
|
{ headers: { "Content-Type": "multipart/form-data" } },
|
|
);
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
finalizeClearance: (id: string) =>
|
|
postBooking<BookingDetail>(`/bookings/${id}/clearance/finalize`),
|
|
|
|
staffAccept: (id: string, validityDays: number) =>
|
|
postBooking<BookingDetail>(B.STAFF_ACCEPT(id), { validityDays }),
|
|
|
|
requestChanges: (id: string, note: string) =>
|
|
postBooking<BookingDetail>(B.STAFF_REQUEST_CHANGES(id), { note }),
|
|
|
|
staffReject: (id: string, reason: string) =>
|
|
postBooking<BookingDetail>(B.STAFF_REJECT(id), { reason }),
|
|
|
|
/** Marketing/operations review of a drawdown order's operation request. */
|
|
reviewOperation: (
|
|
id: string,
|
|
decision: "ACCEPT" | "REQUEST_CHANGES",
|
|
options: { note?: string } = {},
|
|
) =>
|
|
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
|
|
decision,
|
|
...options,
|
|
}),
|
|
|
|
/**
|
|
* Re-request operation on a booking Operations sent back for changes. The
|
|
* customer path uses the same endpoint from the portal; GL needs it here
|
|
* because a customs booking is GL's to fix, not the customer's.
|
|
*/
|
|
proceedToOperation: (id: string, scheduledDate: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
|
|
|
|
generateContract: (id: string) =>
|
|
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
|
|
|
getContractView: async (id: string): Promise<ContractView> => {
|
|
const response = await client.get<ContractView>(B.CONTRACT_VIEW(id));
|
|
return unwrap(response.data) as ContractView;
|
|
},
|
|
|
|
downloadContract: async (id: string): Promise<Blob> => {
|
|
const response = await client.get(B.CONTRACT_DOWNLOAD(id), {
|
|
responseType: "blob",
|
|
});
|
|
return ensurePdfBlob(response.data as Blob);
|
|
},
|
|
|
|
downloadContractDocument: async (id: string): Promise<Blob> => {
|
|
const response = await client.get(B.CONTRACT_DOCUMENT(id), {
|
|
responseType: "blob",
|
|
});
|
|
return ensurePdfBlob(response.data as Blob);
|
|
},
|
|
|
|
signContract: (id: string, payload: SignContractPayload) =>
|
|
postBooking<BookingDetail>(B.CONTRACT_SIGN(id), payload),
|
|
|
|
getSummary: async (id: string): Promise<{ summary: string }> => {
|
|
const response = await client.get<{ summary: string }>(B.SUMMARY(id));
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
getConsolidationDetails: async (
|
|
id: string,
|
|
): Promise<ConsolidationDetails> => {
|
|
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
|
|
return unwrap(response.data) as ConsolidationDetails;
|
|
},
|
|
|
|
customerSign: (id: string, payload: SignContractPayload) =>
|
|
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
|
|
...payload,
|
|
role: "CUSTOMER",
|
|
}),
|
|
|
|
marketingApprove: (id: string, payload: SignContractPayload) =>
|
|
postBooking<BookingDetail>(B.MARKETING_APPROVE(id), {
|
|
...payload,
|
|
role: "STAFF",
|
|
}),
|
|
|
|
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
|
|
|
|
startTransit: (id: string) =>
|
|
postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
|
|
|
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
|
|
|
|
cancel: (id: string, reason: string) =>
|
|
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
|
|
|
|
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
|
|
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
|
|
B.BASE,
|
|
payload,
|
|
);
|
|
const data = unwrap(response.data) as { booking?: BookingDetail };
|
|
return (data.booking ?? data) as BookingDetail;
|
|
},
|
|
|
|
getReferenceData: async () => {
|
|
const response = await client.get(B.REFERENCE_DATA);
|
|
return unwrap(response.data);
|
|
},
|
|
|
|
governmentExpedite: (id: string) =>
|
|
postBooking<BookingDetail>(B.GOVERNMENT_EXPEDITE(id)),
|
|
|
|
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
|
const response = await client.get(B.CLEARANCE(id));
|
|
return unwrap(response.data) as Freight.ClearanceView;
|
|
},
|
|
|
|
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
|
|
requestTransitAssignee: (id: string, note?: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), {
|
|
note,
|
|
}),
|
|
|
|
/** GL Djibouti picks (or changes) that officer — unblocks the declaration. */
|
|
assignTransitAssignee: (id: string, transitAgentId: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
|
|
transitAgentId,
|
|
}),
|
|
|
|
uploadDeclaration: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(B.CLEARANCE_DECLARATION(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
adviseDuty: async (
|
|
id: string,
|
|
payload: {
|
|
dutyRequired: boolean;
|
|
amount?: number;
|
|
currency?: string;
|
|
declarationSerial?: string;
|
|
attachment?: File | null;
|
|
},
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
form.append("dutyRequired", String(payload.dutyRequired));
|
|
if (payload.amount != null) form.append("amount", String(payload.amount));
|
|
if (payload.currency) form.append("currency", payload.currency);
|
|
if (payload.declarationSerial) {
|
|
form.append("declarationSerial", payload.declarationSerial);
|
|
}
|
|
if (payload.attachment) form.append("attachment", payload.attachment);
|
|
const response = await client.post(B.CLEARANCE_DUTY(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
uploadDraftDeclaration: async (
|
|
id: string,
|
|
files: File[],
|
|
price: number,
|
|
currency: string,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
|
|
form.append("price", String(price));
|
|
form.append("currency", currency);
|
|
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
finalizePreClearance: (id: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
|
|
|
uploadTransitPermit: async (
|
|
id: string,
|
|
files: Record<string, File | null>,
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
for (const [key, file] of Object.entries(files)) {
|
|
if (file) form.append(key, file);
|
|
}
|
|
const response = await client.post(B.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
uploadDeliveryOrder: async (
|
|
id: string,
|
|
file: File,
|
|
dates: { vesselArrivalDate: string; doCollectedDate: string },
|
|
): Promise<BookingDetail> => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
form.append("vesselArrivalDate", dates.vesselArrivalDate);
|
|
form.append("doCollectedDate", dates.doCollectedDate);
|
|
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as BookingDetail;
|
|
},
|
|
|
|
uploadReleaseOrder: async (
|
|
id: string,
|
|
file: File,
|
|
vesselDepartureDate: string,
|
|
): Promise<{ hold?: boolean; holdReason?: string }> => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
form.append("vesselDepartureDate", vesselDepartureDate);
|
|
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
});
|
|
return unwrap(response.data) as { hold?: boolean; holdReason?: string };
|
|
},
|
|
|
|
requestRoAmendment: (id: string, note?: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_RO_AMENDMENT(id), { note }),
|
|
|
|
confirmExportRelease: (id: string) =>
|
|
postBooking<BookingDetail>(B.CLEARANCE_EXPORT_RELEASE(id), {}),
|
|
|
|
getEtClearanceQueue: async (): Promise<BookingDetail[]> => {
|
|
const response = await client.get(B.CLEARANCE_ET_QUEUE);
|
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
|
},
|
|
|
|
setExportHandoverMode: async (
|
|
id: string,
|
|
exportHandoverMode: "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
|
): Promise<void> => {
|
|
await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode });
|
|
},
|
|
|
|
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
|
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
|
responseType: "blob",
|
|
});
|
|
return ensurePdfBlob(response.data as Blob);
|
|
},
|
|
|
|
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
|
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
|
},
|
|
};
|
|
|
|
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
|
|
if (blob.type.includes("application/json")) {
|
|
const body = JSON.parse(await blob.text()) as { message?: string };
|
|
throw new Error(body.message ?? "Contract PDF download failed");
|
|
}
|
|
return blob;
|
|
}
|