Files
edr-platform/apps/edr-freight-web/portal/src/services/contracts.service.ts
Marshal 2429f6b629 implement contract cancellation feature and update contract statuses
- Added functionality to cancel contracts, allowing users to provide a reason for cancellation.
- Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED.
- Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses.
- Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts.
- Removed clearance document management from the contract detail page, as it is now handled per booking.
- Introduced a SQL script to reset bookings and train schedules for development purposes.
2026-07-28 05:02:58 +00:00

472 lines
15 KiB
TypeScript

import type { Freight, PaginatedResponse } from "@edr/types";
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
import type { ContractView, SignContractPayload } from "./bookings.service";
const C = URL_CONSTANTS.CONTRACTS;
export type CreateContractPayload = Freight.CreateContractDto;
export type UpdateContractPayload = Freight.UpdateContractDto;
/** Document files keyed by upload field — same shape as booking documents. */
export type ContractDocuments = Record<string, File | File[] | null>;
/**
* Unit-rate pricing returned by generate-price. Mirrors the contract
* `pricing_breakdown` (doc §9.1) — line items with a per-unit price and NO
* total, since quantities are unknown at the contract stage.
*/
export interface GenerateContractPriceResponse {
contractId: string;
currency: string;
lineItems: Freight.ContractUnitRateLineItem[];
warnings?: string[];
}
export interface SubmitContractResponse {
contractId: string;
status: Freight.ContractStatus;
priceChanged: boolean;
currency: string;
lineItems?: Freight.ContractUnitRateLineItem[];
message?: string;
}
/** A container line whose total VGM exceeds the weight-limit rule. */
export interface OverweightLine {
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}
/**
* One line of the server-priced booking breakdown — the exact line the booking
* will persist at create time (rail freight, first/last mile, surcharges…).
*/
export interface ShipmentPriceLine {
code: string;
description: string;
amount: number;
unitAmount: number;
/** Rate unit as stored: PER_CONTAINER | PER_WAGON | PER_TON | PER_KM | FLAT | … */
unit: string;
quantity: number;
currency: string;
}
/**
* Pre-submit validation + authoritative price preview for a shipment booking.
* `overweightLines` are WARNINGS only (an overweight surcharge applies — the
* customer may still submit); `pairingErrors` are HARD BLOCKS (20ft containers
* that cannot be balanced onto wagons) and must prevent booking.
* `lineItems`/`totalAmount` are the full server-computed breakdown — the same
* BookingPricingService pass that prices the booking on create, so the confirm
* modal shows first/last mile, overweight, and every surcharge, not just the
* container estimate.
*/
export interface ShipmentValidation {
overweightLines: OverweightLine[];
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
/** Containers already on another active booking for the same day + route — booking cannot be created. */
containerClashErrors?: string[];
/** EXPORT only: no single open train on the chosen day can carry the whole booking — booking cannot be created. */
spaceErrors?: string[];
lineItems?: ShipmentPriceLine[];
totalAmount?: number;
}
export interface ContractListFilter {
status?: string;
statuses?: string;
/** ONE_TIME or GENERAL. */
contractKind?: string;
/** CONTAINER or BULK. */
freightType?: string;
/** IMPORT / EXPORT / DOMESTIC. */
tradeDirection?: string;
companyProfileId?: string;
createdFrom?: string;
createdTo?: string;
/** Server-side free-text search (contract reference, company name). */
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
function appendScalar(formData: FormData, key: string, value: unknown) {
if (value === undefined || value === null) return;
if (typeof value === "boolean") {
formData.append(key, value ? "true" : "false");
return;
}
if (typeof value === "number") {
formData.append(key, String(value));
return;
}
if (typeof value === "string") {
formData.append(key, value);
}
}
function appendDocuments(formData: FormData, documents?: ContractDocuments) {
if (!documents) return;
for (const [key, fileOrFiles] of Object.entries(documents)) {
if (!fileOrFiles) continue;
if (Array.isArray(fileOrFiles)) {
for (const f of fileOrFiles) formData.append(key, f);
} else {
formData.append(key, fileOrFiles);
}
}
}
/**
* Flatten a contract payload (and optional intake documents) into multipart
* FormData. The cargo-scope and route arrays are sent index-bracketed
* (`cargoScope[0][containerSize]`, `routes[0][originYardId]`, …), mirroring the
* booking form-data builder.
*/
export function buildContractFormData(
payload: Partial<CreateContractPayload>,
documents?: ContractDocuments,
): FormData {
const formData = new FormData();
const skipKeys = new Set(["cargoScope", "routes"]);
for (const [key, value] of Object.entries(payload)) {
if (skipKeys.has(key)) continue;
appendScalar(formData, key, value);
}
payload.cargoScope?.forEach((scope, i) => {
appendScalar(formData, `cargoScope[${i}][containerSize]`, scope.containerSize);
appendScalar(formData, `cargoScope[${i}][cargoTypeId]`, scope.cargoTypeId);
appendScalar(formData, `cargoScope[${i}][cargoFreeText]`, scope.cargoFreeText);
});
payload.routes?.forEach((route, i) => {
appendScalar(formData, `routes[${i}][originYardId]`, route.originYardId);
appendScalar(
formData,
`routes[${i}][destinationYardId]`,
route.destinationYardId,
);
appendScalar(formData, `routes[${i}][km]`, route.km);
appendScalar(formData, `routes[${i}][sortOrder]`, route.sortOrder ?? i);
});
appendDocuments(formData, documents);
return formData;
}
export const contractsService = {
list: async (
filter: ContractListFilter | void = {},
): Promise<PaginatedResponse<Freight.IContract>> => {
const { data } = await client.get(C.BASE, { params: filter ?? {} });
return data.data;
},
listMy: async (
filter: ContractListFilter | void = {},
): Promise<PaginatedResponse<Freight.IContract>> => {
const { data } = await client.get(C.MY, { params: filter ?? {} });
return data.data;
},
get: async (id: string): Promise<Freight.IContract> => {
const { data } = await client.get(C.BY_ID(id));
return data.data;
},
create: async (
payload: CreateContractPayload,
documents?: ContractDocuments,
): Promise<Freight.IContract> => {
const formData = buildContractFormData(payload, documents);
const { data } = await client.post(C.BASE, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data.contract ?? data.data;
},
update: async (
id: string,
payload: UpdateContractPayload,
documents?: ContractDocuments,
): Promise<Freight.IContract> => {
const formData = buildContractFormData(payload, documents);
const { data } = await client.patch(C.BY_ID(id), formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data.contract ?? data.data;
},
remove: async (id: string): Promise<void> => {
await client.delete(C.BY_ID(id));
},
uploadDocuments: async (
id: string,
files: ContractDocuments,
): Promise<Freight.IContract> => {
const formData = new FormData();
appendDocuments(formData, files);
const { data } = await client.post(C.DOCUMENTS(id), formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data;
},
generatePrice: async (
id: string,
): Promise<GenerateContractPriceResponse> => {
const { data } = await client.post(C.GENERATE_PRICE(id));
return data.data;
},
submit: async (id: string): Promise<SubmitContractResponse> => {
const { data } = await client.post(C.SUBMIT(id));
return data.data;
},
confirmSubmit: async (id: string): Promise<SubmitContractResponse> => {
const { data } = await client.post(C.CONFIRM_SUBMIT(id));
return data.data;
},
generateContract: async (id: string): Promise<Freight.IContract> => {
const { data } = await client.post(C.CONTRACT_GENERATE(id));
return data.data ?? data;
},
getContractView: async (id: string): Promise<ContractView> => {
const { data } = await client.get(C.CONTRACT_VIEW(id));
return data.data ?? data;
},
downloadContractDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(C.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return data;
},
signContract: async (
id: string,
payload: SignContractPayload,
): Promise<Freight.IContract> => {
const { data } = await client.post(C.CONTRACT_SIGN(id), payload);
return data.data ?? data;
},
// Ask the server to send the signing OTP to the signer's own registered phone.
// The client never picks the number (the server resolves it from the
// authenticated user and verifies against the same one), so send and verify
// can't disagree. Returns a masked hint.
sendSigningOtp: async (id: string): Promise<{ sentTo: string }> => {
const { data } = await client.post(C.CONTRACT_SEND_SIGNING_OTP(id));
return data.data ?? data;
},
renew: async (
id: string,
dto: Freight.RenewContractDto,
): Promise<Freight.IContract> => {
const { data } = await client.post(C.RENEW(id), dto);
return data.data ?? data;
},
/**
* Cancel own contract so a fresh one can be requested on the same lane. The
* API rejects it while any shipment on the contract is still live.
*/
cancel: async (id: string, reason?: string): Promise<Freight.IContract> => {
const { data } = await client.post(C.CANCEL(id), { reason });
return data.data ?? data;
},
// ── Pre-booking clearance (Path B) ──
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
const { data } = await client.get(C.CLEARANCE(id));
return data.data ?? data;
},
uploadClearanceDocuments: async (
id: string,
files: Record<string, File | null>,
): Promise<Freight.ContractClearanceView> => {
const formData = new FormData();
for (const [key, file] of Object.entries(files)) {
if (file) formData.append(key, file);
}
const { data } = await client.post(C.CLEARANCE_DOCUMENTS(id), formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
/**
* Reject the advised duty & tax with a reason. The clearance step goes back
* to GL Ethiopia, who re-advises a corrected amount; this can repeat.
*/
disputeContractDuty: async (
id: string,
note: string,
): Promise<Freight.IContract> => {
const { data } = await client.post(C.CLEARANCE_DUTY_DISPUTE(id), { note });
return data.data ?? data;
},
uploadContractDutySlip: async (
id: string,
file: File,
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
const { data } = await client.post(C.CLEARANCE_DUTY_SLIP(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
listBookingRequests: async (id: string): Promise<Freight.IBookingRequest[]> => {
const { data } = await client.get(C.BOOKING_REQUESTS(id));
return data.data ?? data;
},
submitBookingRequest: async (
id: string,
dto: Freight.CreateBookingRequestDto,
): Promise<Freight.IBookingRequest> => {
const { data } = await client.post(C.BOOKING_REQUESTS(id), dto);
return data.data ?? data;
},
cancelBookingRequest: async (reqId: string): Promise<Freight.IBookingRequest> => {
const { data } = await client.post(C.BOOKING_REQUEST_CANCEL(reqId), {});
return data.data ?? data;
},
// ── Booking under contract (Path A customer) ──
createBookingUnderContract: async (
id: string,
dto: Freight.CreateBookingUnderContractDto,
): Promise<Freight.IBooking> => {
const { data } = await client.post(C.BOOKINGS(id), dto);
return data.data.booking ?? data.data;
},
/**
* One-click bare booking instance under a GENERAL non-customs contract — no
* cargo, no date. The instance enters per-booking clearance; the customer
* completes it (cargo + shipment day) once Operations finalizes.
*/
initiateBookingUnderContract: async (
id: string,
contractRouteId?: string,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
C.BOOKINGS_INITIATE(id),
contractRouteId ? { contractRouteId } : {},
);
return data.data.booking ?? data.data;
},
/** Complete an initiated booking after clearance — same DTO as create. */
completeBookingUnderContract: async (
id: string,
bookingId: string,
dto: Freight.CreateBookingUnderContractDto,
): Promise<Freight.IBooking> => {
const { data } = await client.post(C.BOOKINGS_COMPLETE(id, bookingId), dto);
return data.data.booking ?? data.data;
},
/**
* Pre-submit validation of a shipment booking (same DTO as
* `createBookingUnderContract`). Returns overweight warnings and hard-block
* 20ft wagon-pairing errors so the customer can be warned/blocked before the
* booking is created.
*/
validateShipment: async (
id: string,
dto: Freight.CreateBookingUnderContractDto,
): Promise<ShipmentValidation> => {
const { data } = await client.post(C.VALIDATE_SHIPMENT(id), dto);
return data.data ?? data;
},
// ── Milestones ──
getContractMilestones: async (
id: string,
): Promise<Freight.IClearanceMilestone[]> => {
const { data } = await client.get(C.MILESTONES(id));
return data.data ?? data;
},
getBookingMilestones: async (
bookingId: string,
): Promise<Freight.IClearanceMilestone[]> => {
const { data } = await client.get(C.BOOKING_MILESTONES(bookingId));
return data.data ?? data;
},
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
const { data } = await client.get(C.CAPACITY(id));
return (data.data ?? data) as Freight.ContractCapacityLine[];
},
/** Customer uploads the duty/tax payment slip (doc-triggers DUTY_TAX_PAID). */
uploadDutySlip: async (
bookingId: string,
file: File,
): Promise<{ milestoneCompleted: boolean }> => {
const form = new FormData();
form.append("duty_tax_receipt", file);
const { data } = await client.post(C.BOOKING_DUTY_SLIP(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return data.data ?? data;
},
/** Customer attaches the payment slip for the GL final invoice (export). */
uploadFinalInvoiceSlip: async (
bookingId: string,
file: File,
): Promise<{ uploaded: boolean }> => {
const form = new FormData();
form.append("file", file);
const { data } = await client.post(
C.BOOKING_FINAL_INVOICE_SLIP(bookingId),
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data ?? data;
},
/** Customer attaches the slip for the post-arrival additional duty round (import). */
uploadSecondDutySlip: async (
bookingId: string,
file: File,
): Promise<{ milestoneCompleted: boolean }> => {
const form = new FormData();
form.append("file", file);
const { data } = await client.post(
C.BOOKING_SECOND_DUTY_SLIP(bookingId),
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return data.data ?? data;
},
};