Files
edr-platform/apps/edr-freight-web/portal/src/services/bookings.service.ts
Hagernesh ec066f3d29 Fix:
Added assertCapacity() checks before saving (matches single receive)
Added applyCapacityDelta() after save to increment counters
Now validates warehouse → yard → zone capacity hierarchy
Single receive already had both checks; bulk receive was gap.
2026-07-22 13:18:36 +00:00

527 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { BookingDocuments } from "@/pages/bookings/new-booking-form/schema";
import { buildBookingFormData } from "./booking-form-data";
import { client } from "../utils/api";
const B = URL_CONSTANTS.BOOKINGS;
export interface MileVehicleSummary {
plate: string | null;
code: string | null;
driverName: string | null;
containerNumber: string | null;
distanceKm: number | null;
}
export interface MileLegSummary {
status: string;
exactKm: number | null;
remainingPayment: number | null;
currency: string;
invoiced: boolean;
vehicles: MileVehicleSummary[];
}
export interface MileSummaryResponse {
firstMile: MileLegSummary | null;
lastMile: MileLegSummary | null;
}
export type CreateBookingPayload = Freight.CreateBookingDto;
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;
} | null;
}
export interface PriceLineItem {
code: string;
description: string;
/** Computed line total (unitAmount × quantity). */
amount: number;
/** Price for a single unit of this charge (e.g. one 20ft container, one ton). */
unitAmount?: number;
/** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */
unit?: string;
/** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */
quantity?: number;
currency: string;
}
/**
* An announced upcoming/open booking window, shown to every signed-in customer
* regardless of contract. Import trains open a window on one booking day;
* export trains open 24h before departure (first come, first served).
*/
export interface MyBookingWindow {
scheduleId: string;
/** Train schedule reference (e.g. TS-2026-000123), shown on the window card. */
reference: string | null;
/**
* The customer's active contract on this lane, when they hold one — enables
* "Book now" to target it. Null for lanes they have no contract on.
*/
contractId: string | null;
/** ONE_TIME contracts can't draw down against a window — button is hidden. */
contractKind: "ONE_TIME" | "GENERAL" | null;
direction: "IMPORT" | "EXPORT" | null;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
/**
* Full ordered corridor for the window's route — origin, every intermediate
* milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa).
* Falls back to [origin, destination] when the route has no milestones.
*/
routeStations: string[];
}
export interface GeneratePriceResponse {
bookingId: string;
totalAmount: number;
currency: string;
lineItems: PriceLineItem[];
warnings: string[];
}
export interface SubmitBookingResponse {
bookingId: string;
status: string;
priceChanged: boolean;
previousTotalAmount?: number;
totalAmount: number;
currency: string;
lineItems?: PriceLineItem[];
message?: string;
}
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
signerDisplayName: string;
consentText?: string;
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
otp?: string;
}
export interface ApproveDeliveryResponse {
bookingId: string;
inventoryId: string;
approvedAt: string;
signerDisplayName: string;
}
/** One import handover record — booking-level or per truck (EDR last-mile). */
export interface BookingHandoverRecord {
id: string;
reference: string;
truckPlate: string | null;
mileType: "SELF_HAUL" | "EDR_LAST_MILE";
generatedAt: string;
signedAt: string | null;
signerName: string | null;
deliveredAt: string | null;
}
export interface SignHandoverResponse {
handoverId: string;
bookingId: string;
signedAt: string | null;
signerDisplayName: string;
allSigned: boolean;
}
export interface CustomerTruckAssignmentPayload {
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumberToLoad: string;
}
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses (overrides `status` when set). */
statuses?: string;
/** ONE_TIME or GENERAL_CONTRACT. */
bookingType?: string;
/** CONTAINER or BULK. */
freightType?: string;
/** IMPORT / EXPORT / DOMESTIC. */
tradeDirection?: string;
/** Narrow to a single operational profile (importer/exporter/freight_forwarder). */
companyProfileId?: string;
/** Created-date range (ISO). */
createdFrom?: string;
createdTo?: string;
/** Free-text search: booking reference, company name, contract reference (server-side). */
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
export const bookingsService = {
list: async (
filter: BookingListFilter | void = {},
): Promise<PaginatedResponse<Freight.IBooking>> => {
const { data } = await client.get("/api/bookings", { params: filter });
return data.data;
},
get: async (id: string): Promise<Freight.IBooking> => {
const { data } = await client.get(`/api/bookings/${id}`);
return data.data;
},
mileSummary: async (id: string): Promise<MileSummaryResponse> => {
const { data } = await client.get(`/api/bookings/${id}/mile-summary`);
return data.data;
},
assignCustomerTruck: async (
id: string,
payload: CustomerTruckAssignmentPayload,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/customer-truck-assignment`,
payload,
);
return data.data;
},
downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise<Blob> => {
const { data } = await client.get(
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
{ responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined },
);
return data;
},
downloadHandoverDocument: async (inventoryId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/${inventoryId}/handover-document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingHandoverDocument: async (
bookingId: string,
handoverId?: string,
): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handover-document`,
{ responseType: "blob", params: handoverId ? { handoverId } : undefined },
);
return data;
},
listBookingHandovers: async (
bookingId: string,
): Promise<BookingHandoverRecord[]> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/handovers`,
);
return data.data ?? data;
},
signHandover: async (
handoverId: string,
signerName: string,
): Promise<SignHandoverResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/handovers/${handoverId}/sign`,
{ signerName },
);
return data.data ?? data;
},
downloadBookingGrnDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/grn-document`,
{ responseType: "blob" },
);
return data;
},
downloadBookingReleaseDocument: async (bookingId: string): Promise<Blob> => {
const { data } = await client.get(
`/api/warehouse-inventory/bookings/${bookingId}/release-document`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data;
},
create: async (
payload: CreateBookingPayload,
documents?: BookingDocuments,
): Promise<Freight.IBooking> => {
const formData = buildBookingFormData(payload, documents);
const { data } = await client.post("/api/bookings", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data.booking;
},
getReferenceData: async (): Promise<Freight.BookingReferenceData> => {
const { data } = await client.get("/api/bookings/reference-data");
return data.data;
},
update: async (
id: string,
payload: Partial<CreateBookingPayload>,
documents?: BookingDocuments,
): Promise<{ booking: Freight.IBooking; warnings: string[] }> => {
const formData = buildBookingFormData(payload, documents);
const { data } = await client.patch(`/api/bookings/${id}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data;
},
remove: async (id: string): Promise<void> => {
await client.delete(`/api/bookings/${id}`);
},
cancel: async (id: string, reason: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/cancel`, { reason });
return data.data;
},
reject: async (id: string, reason?: string): Promise<Freight.IBooking> => {
const { data } = await client.post(`/api/bookings/${id}/reject`, { reason });
return data.data;
},
generatePrice: async (id: string): Promise<GeneratePriceResponse> => {
const { data } = await client.post(`/api/bookings/${id}/generate-price`);
return data.data;
},
submit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/submit`);
return data.data;
},
confirmSubmit: async (id: string): Promise<SubmitBookingResponse> => {
const { data } = await client.post(`/api/bookings/${id}/confirm-submit`);
return data.data;
},
uploadDocuments: async (
id: string,
files: Record<string, File | File[] | null>,
): Promise<Freight.IBooking> => {
const formData = new FormData();
for (const [key, fileOrFiles] of Object.entries(files)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const f of fileOrFiles) formData.append(key, f);
} else {
formData.append(key, fileOrFiles);
}
}
const { data } = await client.post(
`/api/bookings/${id}/documents`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data;
},
// ── Document clearance ──
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
const { data } = await client.get(`/api/bookings/${id}/clearance`);
return data.data ?? data;
},
submitClearanceDocuments: async (
id: string,
files: Record<string, File | null>,
): Promise<Freight.IBooking> => {
const formData = new FormData();
for (const [key, file] of Object.entries(files)) {
if (file) formData.append(key, file);
}
const { data } = await client.post(
`/api/bookings/${id}/clearance/documents`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data;
},
proceedToOperation: async (
id: string,
scheduledDate: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/clearance/proceed`,
{ scheduledDate },
);
return data.data;
},
uploadBookingClearanceDutySlip: async (
id: string,
file: File,
): Promise<Freight.IBooking> => {
const form = new FormData();
form.append("file", file);
const { data } = await client.post(`/api/bookings/${id}/clearance/duty-slip`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(B.CONTRACT_VIEW(id));
return data.data ?? data;
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(B.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return data;
},
checkPayment: async (orderId: string): Promise<{ status: string }> => {
const { data } = await client.post(`/api/payments/bookings/check-payment/${orderId}`);
return data.data ?? data;
},
signContract: async (
id: string,
payload: SignContractPayload,
): Promise<Freight.IBooking> => {
const { data } = await client.post(B.CONTRACT_SIGN(id), payload);
return data.data ?? data;
},
approveDelivery: async (
id: string,
signerName: string,
): Promise<ApproveDeliveryResponse> => {
const { data } = await client.post(
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
{ signerName },
);
return data.data ?? data;
},
getBookableSchedules: async (
query: Freight.BookableSchedulesQuery = {},
): Promise<Freight.BookableScheduleItem[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKABLE_SCHEDULES,
{ params: query },
);
return data.data;
},
/**
* Day-level pool: the days that have a departure on the route. The customer
* picks a day; the engine assigns the train. No capacity is returned.
*/
getAvailableDays: async (
query: Freight.AvailableDaysQuery = {},
): Promise<string[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
{ params: query },
);
return (data.data as Freight.AvailableDaysResponse).days;
},
// Cargo-aware day pool: only days where a train has remaining capacity AND a
// wagon TYPE that can carry this cargo. `containers`/`containerTypeIds` are
// serialized as JSON string params (the server parses them). Days only — no
// capacity counts are ever returned.
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => {
const { containers, containerTypeIds, ...rest } = query;
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
...(containerTypeIds?.length
? { containerTypeIds: JSON.stringify(containerTypeIds) }
: {}),
},
},
);
return (data.data as Freight.AvailableDaysResponse).days;
},
// Days bookable for an EXISTING booking (operation-request step): the server
// derives the cargo from the booking and applies the wagon-type gate.
getAvailableDaysForBooking: async (bookingId: string): Promise<string[]> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/available-days`,
);
return (data.data as Freight.AvailableDaysResponse).days;
},
// Advisory free-wagon count for a shipment day (planning hint, not enforced).
getDayAvailability: async (
bookingId: string,
date: string,
): Promise<Freight.DayAvailabilityResponse> => {
const { data } = await client.get(
`/api/bookings/${bookingId}/day-availability`,
{ params: { date } },
);
return data.data as Freight.DayAvailabilityResponse;
},
/**
* Upcoming/open booking windows on the signed-in customer's active-contract
* lanes (import booking-day windows + export 24h pre-departure windows).
*/
getMyBookingWindows: async (): Promise<MyBookingWindow[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.MY_BOOKING_WINDOWS,
);
return data.data ?? data;
},
/**
* Booking windows for a single contract's routes (same row shape as
* `getMyBookingWindows`). Used to gate the direct "New shipment booking"
* entry on the contract detail page and the new-shipment form.
*/
getContractBookingWindows: async (
contractId: string,
): Promise<MyBookingWindow[]> => {
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.CONTRACT_BOOKING_WINDOWS(contractId),
);
return data.data ?? data;
},
};