mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1713 lines
66 KiB
TypeScript
1713 lines
66 KiB
TypeScript
/**
|
||
* Freight business steps, ported from e2e/freight/cypress/e2e/flows/import-utils.ts.
|
||
*
|
||
* Same philosophy as that file: contracts are seeded FULLY_EXECUTED in SQL
|
||
* (the wizard is the Cypress suite's job), everything after that — bookings,
|
||
* clearance, staff review, window phases, batching, invoicing, payment — runs
|
||
* through the real API.
|
||
*
|
||
* The one deliberate difference: `settleViaGateway` is gone. Where the Cypress
|
||
* suite fakes an intent row and POSTs to freight's own internal webhook, this
|
||
* suite pays through the real payment microservice and lets the gateway mock
|
||
* call back. That seam is the whole point of the suite.
|
||
*/
|
||
import { join } from "node:path";
|
||
import {
|
||
api,
|
||
apiOk,
|
||
customerA,
|
||
db,
|
||
gateway,
|
||
opsStaff,
|
||
poll,
|
||
superAdmin,
|
||
upload,
|
||
} from "./client";
|
||
|
||
export const CORRIDOR = [
|
||
"DJIB_PORT",
|
||
"NAGAD",
|
||
"DIRE_DAWA",
|
||
"E2E_AWASH",
|
||
"MOJO",
|
||
"KALITY",
|
||
] as const;
|
||
export const ORIGIN = "DJIB_PORT";
|
||
export const DEST = "KALITY";
|
||
/** The export corridor runs the same six stops reversed (ET → DJ). */
|
||
export const EXP_ORIGIN = DEST;
|
||
export const EXP_DEST = ORIGIN;
|
||
|
||
/** Reused from the Cypress fixtures — a tiny PDF that satisfies the doc gate. */
|
||
export const DOC_FIXTURE = join(
|
||
process.cwd(),
|
||
"..",
|
||
"e2e",
|
||
"freight",
|
||
"cypress",
|
||
"fixtures",
|
||
"docs",
|
||
"license.pdf",
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// time — departures pinned to 12:00 EAT so the EAT day key is unambiguous
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export function departureAt(dayOffset: number): Date {
|
||
const eatNow = new Date(Date.now() + 3 * 3_600_000);
|
||
return new Date(
|
||
Date.UTC(eatNow.getUTCFullYear(), eatNow.getUTCMonth(), eatNow.getUTCDate() + dayOffset, 9, 0, 0),
|
||
);
|
||
}
|
||
|
||
export const eatDayStr = (d: Date) =>
|
||
new Date(d.getTime() + 3 * 3_600_000).toISOString().slice(0, 10);
|
||
|
||
/** ISO 6346-shaped container number, unique per run+seed (checksum unchecked). */
|
||
export function isoNumber(runStamp: string, seed: number): string {
|
||
return `MSCU${String((Number(runStamp.slice(-6)) * 100 + seed) % 10_000_000).padStart(7, "0")}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// contracts
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface SeedContractOpts {
|
||
suffix: string;
|
||
reference: string;
|
||
currency?: "ETB" | "USD";
|
||
direction?: "IMPORT" | "EXPORT" | "DOMESTIC";
|
||
freight?: "CONTAINER" | "BULK";
|
||
/** Path B: booked by Global Logistics on the customer's behalf, customs tail after arrival. */
|
||
customs?: boolean;
|
||
originCode?: string;
|
||
destCode?: string;
|
||
/** TIN of the owning company. One tenant per booking — see {@link ensureTenant}. */
|
||
tin?: string;
|
||
/**
|
||
* Service type to sell the contract under. Defaults to the oldest one (RAIL,
|
||
* `includes_customs = false`). `RAIL_CUSTOMS` is what makes the rule engine's
|
||
* CUSTOMS priority band apply — see seed-customs-service-type.sql.
|
||
*/
|
||
serviceTypeCode?: string;
|
||
}
|
||
|
||
export const TIN_A = "0102030405"; // seed-company.sql
|
||
export const TIN_B = "0102030406"; // seed-company-b.sql (this suite)
|
||
|
||
/**
|
||
* Seed a FULLY_EXECUTED one-time contract, container or bulk, self-clearing or
|
||
* customs (Path B — the pre-booking boundary milestone is stamped COMPLETED so
|
||
* the booking gate opens).
|
||
*/
|
||
export async function seedContract(opts: SeedContractOpts): Promise<string> {
|
||
const direction = opts.direction ?? "IMPORT";
|
||
const freight = opts.freight ?? "CONTAINER";
|
||
const customs = opts.customs ?? false;
|
||
const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
|
||
await db(
|
||
`WITH c AS (
|
||
INSERT INTO freight.contracts
|
||
(reference, company_id, company_profile_id, contract_kind,
|
||
trade_direction, freight_type, service_type_id, payment_currency,
|
||
customs_clearing_enabled, clearance_status, status,
|
||
fully_executed_at, contract_valid_from, contract_valid_until,
|
||
contract_summary)
|
||
SELECT $1, comp.id,
|
||
(SELECT p.id FROM freight.company_profiles p
|
||
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
|
||
ORDER BY CASE
|
||
WHEN $2::text = 'EXPORT' AND p.type = 'exporter' THEN 0
|
||
WHEN $2::text <> 'EXPORT' AND p.type = 'importer' THEN 0
|
||
ELSE 1
|
||
END
|
||
LIMIT 1),
|
||
'ONE_TIME', $2::text, $7::text,
|
||
(SELECT st.id FROM freight.service_types st
|
||
WHERE $10::text IS NULL OR st.code = $10::text
|
||
ORDER BY st.created_at LIMIT 1),
|
||
$3, $8,
|
||
CASE WHEN $8 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END,
|
||
'FULLY_EXECUTED',
|
||
now(), now() - interval '1 day', now() + interval '60 days',
|
||
'IT payment-integration fixture contract'
|
||
FROM freight.companies comp
|
||
WHERE comp.tin = $4
|
||
RETURNING id
|
||
), r AS (
|
||
INSERT INTO freight.contract_routes
|
||
(contract_id, origin_yard_id, destination_yard_id, sort_order)
|
||
SELECT c.id, o.id, d.id, 0 FROM c
|
||
JOIN freight.yards o ON o.code = $5
|
||
JOIN freight.yards d ON d.code = $6
|
||
RETURNING id
|
||
), scope_container AS (
|
||
INSERT INTO freight.contract_cargo_scope
|
||
(contract_id, container_size, cargo_free_text)
|
||
SELECT c.id, v.size, 'IT corridor cargo'
|
||
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
|
||
WHERE $7::text = 'CONTAINER'
|
||
), scope_bulk AS (
|
||
INSERT INTO freight.contract_cargo_scope
|
||
(contract_id, cargo_type_id, cargo_free_text)
|
||
SELECT c.id, ct.id, 'IT corridor wheat'
|
||
FROM c JOIN freight.cargo_types ct ON ct.code = 'E2E_IMP_WHEAT'
|
||
WHERE $7::text = 'BULK'
|
||
)
|
||
-- Path B gate: a customs ONE_TIME booking needs the pre-booking boundary
|
||
-- milestone COMPLETED (IMPORT → DO_COLLECTED, EXPORT → EXPORT_RELEASED).
|
||
INSERT INTO freight.clearance_milestones
|
||
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
|
||
SELECT c.id, $9, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0
|
||
FROM c WHERE $8`,
|
||
[
|
||
opts.reference,
|
||
direction,
|
||
opts.currency ?? "ETB",
|
||
opts.tin ?? TIN_A,
|
||
opts.originCode ?? (direction === "EXPORT" ? DEST : ORIGIN),
|
||
opts.destCode ?? (direction === "EXPORT" ? ORIGIN : DEST),
|
||
freight,
|
||
customs,
|
||
boundary,
|
||
opts.serviceTypeCode ?? null,
|
||
],
|
||
);
|
||
const rows = await db<{ id: string }>(
|
||
`SELECT id FROM freight.contracts WHERE reference = $1 ORDER BY created_at DESC LIMIT 1`,
|
||
[opts.reference],
|
||
);
|
||
if (!rows[0]) throw new Error(`contract ${opts.reference} was not seeded`);
|
||
return rows[0].id;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// route + schedule
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function routeId(originCode = ORIGIN, destCode = DEST): Promise<string | null> {
|
||
const rows = await db<{ id: string }>(
|
||
`SELECT r.id FROM freight.routes r
|
||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
|
||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
|
||
WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`,
|
||
[originCode, destCode],
|
||
);
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
|
||
/** Create the 6-stop corridor route through the API if it doesn't exist yet. */
|
||
export async function ensureCorridorRoute(): Promise<void> {
|
||
if (await routeId()) return;
|
||
const yards = await db<{ id: string; code: string }>(
|
||
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
|
||
[[...CORRIDOR]],
|
||
);
|
||
if (yards.length !== CORRIDOR.length) {
|
||
throw new Error(`corridor yards missing: got ${yards.length}/${CORRIDOR.length}`);
|
||
}
|
||
const byCode = new Map(yards.map((y) => [y.code, y.id]));
|
||
await apiOk(opsStaff, "post", "/api/routes", {
|
||
milestones: CORRIDOR.map((code) => ({ yardId: byCode.get(code) })),
|
||
});
|
||
}
|
||
|
||
/** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */
|
||
export async function ensureExportRoute(): Promise<void> {
|
||
if (await routeId(EXP_ORIGIN, EXP_DEST)) return;
|
||
const stops = [...CORRIDOR].reverse();
|
||
const yards = await db<{ id: string; code: string }>(
|
||
`SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
|
||
[stops],
|
||
);
|
||
if (yards.length !== stops.length) throw new Error("corridor yards missing");
|
||
const byCode = new Map(yards.map((y) => [y.code, y.id]));
|
||
await apiOk(opsStaff, "post", "/api/routes", {
|
||
milestones: stops.map((code) => ({ yardId: byCode.get(code) })),
|
||
});
|
||
}
|
||
|
||
export interface ScheduleRow {
|
||
id: string;
|
||
status: string;
|
||
window_phase: string;
|
||
booking_window_status: string;
|
||
booking_cycle_no: number;
|
||
max_wagons: number;
|
||
scheduled_departure_date: string;
|
||
[k: string]: unknown;
|
||
}
|
||
|
||
const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_status,
|
||
ts.booking_cycle_no, ts.max_wagons, ts.scheduled_departure_date`;
|
||
|
||
export async function findSchedule(
|
||
departure: Date,
|
||
originCode = ORIGIN,
|
||
destCode = DEST,
|
||
windowSeconds = 3600,
|
||
): Promise<ScheduleRow | undefined> {
|
||
const rows = await db<ScheduleRow>(
|
||
`SELECT ${SCHEDULE_COLS}
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||
WHERE ts.deleted_at IS NULL
|
||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < $4
|
||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||
[originCode, destCode, departure.toISOString(), windowSeconds],
|
||
);
|
||
return rows[0];
|
||
}
|
||
|
||
export const findExportSchedule = (departure: Date) =>
|
||
findSchedule(departure, EXP_ORIGIN, EXP_DEST);
|
||
|
||
/**
|
||
* Wipe whatever a previous file left on this departure day. Same intent as
|
||
* resetCorridorDay in import-utils.ts: a schedule must never be soft-deleted
|
||
* while bookings still point at it, and leftover allocations keep eating wagons.
|
||
*/
|
||
export async function resetCorridorDay(
|
||
departure: Date,
|
||
originCode = ORIGIN,
|
||
destCode = DEST,
|
||
): Promise<void> {
|
||
await db(
|
||
`WITH stale AS (
|
||
SELECT ts.id FROM freight.train_schedules ts
|
||
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||
WHERE ts.deleted_at IS NULL
|
||
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 43200
|
||
), unlink AS (
|
||
UPDATE freight.bookings b
|
||
SET train_schedule_id = NULL,
|
||
status = CASE WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT')
|
||
THEN 'EXPIRED' ELSE b.status END,
|
||
scheduling_status = 'NOT_SCHEDULED',
|
||
-- and its shipment DAY, or rescueStrandedPaidForDay re-places it:
|
||
-- that sweep takes any unlinked booking with payment_status PAID and
|
||
-- a scheduled_date on the day, so a previous run's paid fixtures
|
||
-- climb straight back onto the fresh schedule (18 stowaway wagons
|
||
-- the file never booked).
|
||
scheduled_date = NULL
|
||
FROM freight.contracts ct
|
||
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%'
|
||
AND b.train_schedule_id IN (SELECT id FROM stale)
|
||
), drop_links AS (
|
||
UPDATE freight.train_schedule_bookings SET deleted_at = now()
|
||
WHERE train_schedule_id IN (SELECT id FROM stale) AND deleted_at IS NULL
|
||
), free_wagons AS (
|
||
UPDATE freight.wagon_booking_allocations wba SET deleted_at = now()
|
||
FROM freight.bookings b
|
||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||
AND ct.reference LIKE 'CTR-IT-%'
|
||
AND b.train_schedule_id IN (SELECT id FROM stale)
|
||
)
|
||
UPDATE freight.train_schedules SET deleted_at = now()
|
||
WHERE id IN (SELECT id FROM stale)`,
|
||
[originCode, destCode, departure.toISOString()],
|
||
);
|
||
// Unpinned leftovers from earlier files would contaminate this day's batch.
|
||
await db(
|
||
`UPDATE freight.bookings b SET status = 'EXPIRED'
|
||
FROM freight.contracts ct
|
||
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%'
|
||
AND b.status = 'FULLY_EXECUTED' AND b.train_schedule_id IS NULL`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Expire this suite's leftover unpaid holds.
|
||
*
|
||
* A company sitting on a SELECTED_FOR_BATCH / AWAITING_PAYMENT booking cannot
|
||
* create another one (contract-booking.service.ts assertNoUnpaidHold) — a real
|
||
* rule, and the reason each spec file must start from a clean tenant. Scoped to
|
||
* `CTR-IT-%` contracts, so no other suite's data is ever touched.
|
||
*/
|
||
export async function releaseUnpaidHolds(): Promise<void> {
|
||
await db(
|
||
`UPDATE freight.bookings b
|
||
SET status = 'EXPIRED', train_schedule_id = NULL, scheduling_status = 'NOT_SCHEDULED',
|
||
-- Dropping the shipment day is what makes the retirement stick: the
|
||
-- stranded-PAID sweep re-places any unlinked booking that still has
|
||
-- payment_status PAID and a scheduled_date on the day being filled.
|
||
scheduled_date = NULL
|
||
FROM freight.contracts ct
|
||
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%'
|
||
AND b.status IN ('SELECTED_FOR_BATCH','AWAITING_PAYMENT','FULLY_EXECUTED','PAID')`,
|
||
);
|
||
// Same treatment for fixtures that were ALREADY terminal: a booking the
|
||
// engine expired (or an earlier run retired) still carries payment_status
|
||
// PAID and its shipment day, which is all rescueStrandedPaidForDay needs to
|
||
// put it back on today's train. The status filter above never sees those
|
||
// rows, so sweep them here.
|
||
await db(
|
||
`UPDATE freight.bookings b SET scheduled_date = NULL
|
||
FROM freight.contracts ct
|
||
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%'
|
||
AND b.train_schedule_id IS NULL AND b.scheduled_date IS NOT NULL
|
||
AND b.status IN ('EXPIRED','CANCELLED','REJECTED')`,
|
||
);
|
||
// Physical wagon stock is finite and every earlier file's allocations still
|
||
// hold theirs (bookings that PAID and never "arrived" keep their wagons).
|
||
// Freeing them here is safe because this runs at file start, before the file
|
||
// books anything of its own — and without it the fifth or sixth file on the
|
||
// same stack silently gets a short consist.
|
||
await db(
|
||
`UPDATE freight.wagon_booking_allocations wba SET deleted_at = now()
|
||
FROM freight.bookings b
|
||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL
|
||
AND ct.reference LIKE 'CTR-IT-%'`,
|
||
);
|
||
// train_schedule_bookings.booking_id is UNIQUE and the constraint ignores
|
||
// deleted_at, so a soft-unlinked booking can never be re-batched: on a warm
|
||
// DB the next batch dies with "already exists for 'Booking Id'". These rows
|
||
// belong to retired fixture bookings, so drop them outright.
|
||
await db(
|
||
`DELETE FROM freight.train_schedule_bookings tsb
|
||
USING freight.bookings b
|
||
JOIN freight.contracts ct ON ct.id = b.contract_id
|
||
WHERE tsb.booking_id = b.id AND ct.reference LIKE 'CTR-IT-%'
|
||
AND b.status IN ('EXPIRED','CANCELLED','REJECTED')`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Ops creates a loco-pair schedule — capacity comes from maxWagonsPerTrain
|
||
* (loco-pair mode); wagon stock is drawn from the origin yard at allocation.
|
||
* `kind` picks the wagon family: container → NW5, bulk → CW4.
|
||
*/
|
||
export async function createSchedule(opts: {
|
||
departure: Date;
|
||
maxWagons?: number;
|
||
locoPair?: [string, string];
|
||
kind?: "container" | "bulk";
|
||
originCode?: string;
|
||
destCode?: string;
|
||
}): Promise<ScheduleRow> {
|
||
const originCode = opts.originCode ?? ORIGIN;
|
||
const destCode = opts.destCode ?? DEST;
|
||
const existing = await findSchedule(opts.departure, originCode, destCode);
|
||
if (!existing) {
|
||
const route = await routeId(originCode, destCode);
|
||
if (!route) throw new Error(`route ${originCode}→${destCode} missing`);
|
||
const locos = await db<{ id: string }>(
|
||
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
|
||
[opts.locoPair ?? ["LOCO-IMP-1", "LOCO-IMP-2"]],
|
||
);
|
||
if (locos.length !== 2) throw new Error("schedule locomotives missing");
|
||
await apiOk(opsStaff, "post", `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, {
|
||
routeId: route,
|
||
scheduleDate: opts.departure.toISOString(),
|
||
locomotiveIds: locos.map((l) => l.id),
|
||
maxWagonsPerTrain: opts.maxWagons ?? 54,
|
||
});
|
||
}
|
||
const schedule = await findSchedule(opts.departure, originCode, destCode);
|
||
if (!schedule) throw new Error("schedule was not created");
|
||
return schedule;
|
||
}
|
||
|
||
/** Back-compat alias for the container-import payment specs. */
|
||
export const createImportSchedule = (opts: {
|
||
departure: Date;
|
||
maxWagons?: number;
|
||
locoPair?: [string, string];
|
||
}) => createSchedule(opts);
|
||
|
||
export function scheduleRow(scheduleId: string) {
|
||
return db<ScheduleRow>(
|
||
`SELECT ${SCHEDULE_COLS} , ts.window_opens_at, ts.window_closes_at, ts.payment_phase_ends_at
|
||
FROM freight.train_schedules ts WHERE ts.id = $1`,
|
||
[scheduleId],
|
||
).then((rows) => rows[0]);
|
||
}
|
||
|
||
async function pollSchedulePhase(scheduleId: string, want: string[], attempts = 40) {
|
||
return poll<{ window_phase: string; booking_window_status: string }>(
|
||
`schedule ${scheduleId} → ${want.join("|")}`,
|
||
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||
[scheduleId],
|
||
(row) => !!row && want.includes(row.window_phase),
|
||
{ attempts },
|
||
);
|
||
}
|
||
|
||
/** Pull window_opens_at into the past; the app's 10s tick flips PRE_WINDOW→OPEN. */
|
||
export async function forceWindowOpen(scheduleId: string, closesInMinutes = 45): Promise<void> {
|
||
await db(
|
||
`UPDATE freight.train_schedules
|
||
SET window_opens_at = now() - interval '1 minute',
|
||
window_closes_at = now() + ($2 || ' minutes')::interval
|
||
WHERE id = $1`,
|
||
[scheduleId, String(closesInMinutes)],
|
||
);
|
||
await pollSchedulePhase(scheduleId, ["OPEN"]);
|
||
await poll(
|
||
`schedule ${scheduleId} bookable`,
|
||
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
|
||
[scheduleId],
|
||
(row) => (row as { booking_window_status?: string })?.booking_window_status === "OPEN",
|
||
);
|
||
}
|
||
|
||
export async function closeBookingWindow(scheduleId: string): Promise<void> {
|
||
await db(
|
||
`UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second'
|
||
WHERE id = $1 AND window_phase = 'OPEN'`,
|
||
[scheduleId],
|
||
);
|
||
await pollSchedulePhase(scheduleId, ["DOC_REVIEW"]);
|
||
}
|
||
|
||
/**
|
||
* Staff end document review early → PAYMENT: the priority batch runs over the
|
||
* route-day pool, reserves wagons and ISSUES THE INVOICES this suite pays.
|
||
*/
|
||
export async function completeDocReview(scheduleId: string): Promise<void> {
|
||
await apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`);
|
||
await pollSchedulePhase(scheduleId, ["PAYMENT", "DONE", "PRE_WINDOW"]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// bookings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface BookingRow {
|
||
id: string;
|
||
reference: string;
|
||
status: string;
|
||
scheduling_status: string;
|
||
train_schedule_id: string | null;
|
||
payment_deadline: string | null;
|
||
contract_id: string;
|
||
[k: string]: unknown;
|
||
}
|
||
|
||
export async function bookingFor(contractId: string): Promise<BookingRow> {
|
||
const rows = await db<BookingRow>(
|
||
`SELECT b.id, b.reference, b.status, b.scheduling_status, b.train_schedule_id,
|
||
b.payment_deadline, b.contract_id
|
||
FROM freight.bookings b
|
||
WHERE b.contract_id = $1 AND b.deleted_at IS NULL
|
||
ORDER BY b.created_at DESC LIMIT 1`,
|
||
[contractId],
|
||
);
|
||
if (!rows[0]) throw new Error(`no booking under contract ${contractId}`);
|
||
return rows[0];
|
||
}
|
||
|
||
export function pollBookingStatus(bookingId: string, status: string | string[], attempts = 30) {
|
||
const want = Array.isArray(status) ? status : [status];
|
||
return poll<BookingRow>(
|
||
`booking ${bookingId} → ${want.join("|")}`,
|
||
`SELECT status, scheduling_status FROM freight.bookings WHERE id = $1`,
|
||
[bookingId],
|
||
(row) => !!row && want.includes(row.status),
|
||
{ attempts },
|
||
);
|
||
}
|
||
|
||
/** Customer books containers under a seeded contract. */
|
||
export async function bookContainers(opts: {
|
||
contractId: string;
|
||
runStamp: string;
|
||
isoSeed: number;
|
||
twenty?: number;
|
||
forty?: number;
|
||
scheduledDate: string;
|
||
vgmTons?: number;
|
||
as?: string;
|
||
}) {
|
||
const vgm = opts.vgmTons ?? 10;
|
||
const lines: Array<Record<string, unknown>> = [];
|
||
let unit = 0;
|
||
const line = (size: string, qty: number) => ({
|
||
containerSize: size,
|
||
quantity: qty,
|
||
units: Array.from({ length: qty }, () => ({
|
||
containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++),
|
||
vgmTons: vgm,
|
||
})),
|
||
});
|
||
if (opts.twenty) lines.push(line("20ft", opts.twenty));
|
||
if (opts.forty) lines.push(line("40ft", opts.forty));
|
||
|
||
return api(opts.as ?? customerA, "post", `/api/contracts/${opts.contractId}/bookings`, {
|
||
scheduledDate: opts.scheduledDate,
|
||
containers: lines,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Upload one ad-hoc doc → GL approves → finalize → customer proceeds with the
|
||
* shipment day. The e2e seed configures no required documents, so a single doc
|
||
* satisfies the 100%-approved gate.
|
||
*/
|
||
export async function clearBooking(
|
||
bookingId: string,
|
||
scheduledDate: string,
|
||
as = customerA,
|
||
): Promise<void> {
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e");
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, {
|
||
fileKey: "custom_e2e",
|
||
status: "APPROVED",
|
||
});
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/finalize`);
|
||
await apiOk(as, "post", `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate });
|
||
await pollBookingStatus(bookingId, "OPERATION_REQUEST_PENDING", 20);
|
||
}
|
||
|
||
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
|
||
export async function acceptOperation(bookingId: string): Promise<void> {
|
||
await apiOk(opsStaff, "post", `/api/bookings/${bookingId}/operation/review`, {
|
||
decision: "ACCEPT",
|
||
});
|
||
await pollBookingStatus(bookingId, "FULLY_EXECUTED", 15);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// invoices
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface InvoiceRow {
|
||
id: string;
|
||
invoice_number: string;
|
||
status: string;
|
||
total_amount: string;
|
||
balance_amount: string | null;
|
||
paid_amount: string | null;
|
||
currency: string;
|
||
payment_id: string | null;
|
||
source: string;
|
||
source_id: string;
|
||
paid_at: string | null;
|
||
[k: string]: unknown;
|
||
}
|
||
|
||
export function invoiceForBooking(bookingId: string, attempts = 30) {
|
||
return poll<InvoiceRow>(
|
||
`invoice for booking ${bookingId}`,
|
||
`SELECT * FROM freight.invoices
|
||
WHERE source_id = $1 AND source = 'booking' AND deleted_at IS NULL
|
||
ORDER BY created_at DESC LIMIT 1`,
|
||
[bookingId],
|
||
(row) => !!row,
|
||
{ attempts },
|
||
);
|
||
}
|
||
|
||
export function currentInvoice(invoiceId: string) {
|
||
return db<InvoiceRow>(`SELECT * FROM freight.invoices WHERE id = $1`, [invoiceId]).then(
|
||
(rows) => rows[0],
|
||
);
|
||
}
|
||
|
||
/** The freight-side projection of a gateway intent. */
|
||
export function freightPayment(intentId: string) {
|
||
return db<{ id: string; status: string; merchant_order_id: string; transaction_id: string | null }>(
|
||
`SELECT id, status, merchant_order_id, transaction_id FROM freight.payments WHERE id = $1`,
|
||
[intentId],
|
||
).then((rows) => rows[0]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// payment
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Customer pays an invoice from the portal. This is the real chain:
|
||
* portal → billing.payInvoice → PaymentClientService → payment API
|
||
* → provider (gateway mock) → intent, and the invoice gets `payment_id`.
|
||
*/
|
||
export function payInvoice(
|
||
invoiceId: string,
|
||
opts: { method?: string; as?: string; payerAccount?: string } = {},
|
||
) {
|
||
return api(opts.as ?? customerA, "post", `/api/billing/my-invoices/${invoiceId}/pay`, {
|
||
method: opts.method ?? "CBE_BIRR",
|
||
platform: "web",
|
||
...(opts.payerAccount ? { payerAccount: opts.payerAccount } : {}),
|
||
});
|
||
}
|
||
|
||
/** The gateway intent the payment service opened for a booking. */
|
||
export function gatewayIntent(bookingId: string, attempts = 15) {
|
||
return poll<{
|
||
id: string;
|
||
status: string;
|
||
merchant_order_id: string;
|
||
amount_minor: string;
|
||
currency: string;
|
||
provider: string;
|
||
expires_at: string | null;
|
||
}>(
|
||
`payment intent for booking ${bookingId}`,
|
||
`SELECT id, status, merchant_order_id, amount_minor, currency, provider, expires_at
|
||
FROM edr_payment.payment_intent
|
||
WHERE reference_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||
[bookingId],
|
||
(row) => !!row,
|
||
{ attempts, intervalMs: 1000 },
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// the whole arrange chain, in one call
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface ReadyBooking {
|
||
contractId: string;
|
||
bookingId: string;
|
||
}
|
||
|
||
/**
|
||
* Contract → booking → clearance → ops accept, ending in the day pool
|
||
* (FULLY_EXECUTED). Invoices are not issued yet: that happens when the batch
|
||
* runs — see {@link runBatch}. Roughly 30–60s of real API work per booking.
|
||
*/
|
||
export async function prepareBooking(opts: {
|
||
suffix: string;
|
||
departure: Date;
|
||
runStamp: string;
|
||
isoSeed: number;
|
||
twenty?: number;
|
||
forty?: number;
|
||
currency?: "ETB" | "USD";
|
||
tin?: string;
|
||
as?: string;
|
||
}): Promise<ReadyBooking> {
|
||
const contractId = await seedContract({
|
||
suffix: opts.suffix,
|
||
reference: `CTR-IT-${opts.runStamp}-${opts.suffix}`,
|
||
currency: opts.currency,
|
||
tin: opts.tin,
|
||
});
|
||
const day = eatDayStr(opts.departure);
|
||
const res = await bookContainers({
|
||
contractId,
|
||
runStamp: opts.runStamp,
|
||
isoSeed: opts.isoSeed,
|
||
twenty: opts.twenty ?? 2,
|
||
forty: opts.forty,
|
||
scheduledDate: day,
|
||
as: opts.as,
|
||
});
|
||
if (res.status > 201) {
|
||
throw new Error(`booking ${opts.suffix} rejected: ${res.status} ${JSON.stringify(res.body)}`);
|
||
}
|
||
const booking = await bookingFor(contractId);
|
||
await clearBooking(booking.id, day, opts.as ?? customerA);
|
||
await acceptOperation(booking.id);
|
||
return { contractId, bookingId: booking.id };
|
||
}
|
||
|
||
/**
|
||
* Close the booking window and end doc review — the batch reserves wagons by
|
||
* priority and issues the invoices. Everything pooled for the day settles here.
|
||
*/
|
||
export async function runBatch(scheduleId: string): Promise<void> {
|
||
await closeBookingWindow(scheduleId);
|
||
await completeDocReview(scheduleId);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// tenants — one company per booking
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* A company may hold only ONE unpaid reservation at a time
|
||
* (contract-booking.service.ts assertNoUnpaidHold, company-scoped). Every
|
||
* multi-booking scenario here therefore needs a company per booking: without
|
||
* it the second booking of any train-filling scenario is rejected with
|
||
* "You already have a booking waiting for payment".
|
||
*
|
||
* Staff (`superAdmin`) book and pay on the customer's behalf, so these tenants
|
||
* need no portal user — which is also the real Path B flow for customs.
|
||
*/
|
||
export async function ensureTenant(slug: string): Promise<string> {
|
||
const tin = tinFor(slug);
|
||
await db(
|
||
`WITH c AS (
|
||
INSERT INTO freight.companies
|
||
(id, name, type, status, tin, fan_number, country, address, phone, email,
|
||
nationality, kind, attributes)
|
||
SELECT gen_random_uuid(), 'IT Tenant ' || $1::text, 'customer', 'active', $2::text,
|
||
$2::text || '000000', 'Ethiopia', 'Addis Ababa',
|
||
'+2519' || substr($2::text, 3, 8),
|
||
'ops+' || lower($1::text) || '@it-tenant.test', 'ethiopian', 'commercial', '{}'::jsonb
|
||
WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = $2::text)
|
||
RETURNING id
|
||
), pick AS (
|
||
SELECT id FROM c
|
||
UNION ALL
|
||
SELECT id FROM freight.companies WHERE tin = $2::text
|
||
LIMIT 1
|
||
)
|
||
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
|
||
SELECT gen_random_uuid(), pick.id, v.type, 'active', upper(substr(v.type, 1, 3)) || $2::text
|
||
FROM pick CROSS JOIN (VALUES ('importer'), ('exporter')) AS v(type)
|
||
WHERE NOT EXISTS (
|
||
SELECT 1 FROM freight.company_profiles p
|
||
WHERE p.company_id = pick.id AND p.type = v.type
|
||
)`,
|
||
[slug, tin],
|
||
);
|
||
return tin;
|
||
}
|
||
|
||
/** Deterministic 10-digit TIN per tenant slug — stable across runs, unique per slug. */
|
||
export function tinFor(slug: string): string {
|
||
let h = 0;
|
||
for (const ch of slug) h = (h * 31 + ch.charCodeAt(0)) % 100_000_000;
|
||
return `20${String(h).padStart(8, "0")}`;
|
||
}
|
||
|
||
/** Seed one tenant + one contract per suffix, and return suffix → contractId. */
|
||
export async function seedTenantContracts(
|
||
runStamp: string,
|
||
specs: Array<
|
||
{ suffix: string } & Omit<SeedContractOpts, "suffix" | "reference" | "tin">
|
||
>,
|
||
): Promise<Map<string, string>> {
|
||
const out = new Map<string, string>();
|
||
for (const spec of specs) {
|
||
const tin = await ensureTenant(`${runStamp}-${spec.suffix}`);
|
||
out.set(
|
||
spec.suffix,
|
||
await seedContract({
|
||
...spec,
|
||
reference: `CTR-IT-${runStamp}-${spec.suffix}`,
|
||
tin,
|
||
}),
|
||
);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// bulk bookings
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Book bulk tons under a seeded BULK contract. Wagon demand = ceil(tons / 70)
|
||
* on CW4 covered gondolas. Omit `scheduledDate` for DOMESTIC (intercity)
|
||
* bookings — they never pick a shipment day.
|
||
*/
|
||
export async function bookBulk(opts: {
|
||
contractId: string;
|
||
tons: number;
|
||
scheduledDate?: string;
|
||
cargoCode?: string;
|
||
as?: string;
|
||
}) {
|
||
const [row] = await db<{ cargo_type_id: string }>(
|
||
`SELECT t.id AS cargo_type_id FROM freight.cargo_types t WHERE t.code = $1`,
|
||
[opts.cargoCode ?? "E2E_IMP_WHEAT"],
|
||
);
|
||
if (!row) throw new Error(`cargo type ${opts.cargoCode ?? "E2E_IMP_WHEAT"} not seeded`);
|
||
return api(opts.as ?? superAdmin, "post", `/api/contracts/${opts.contractId}/bookings`, {
|
||
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
|
||
bulkLines: [{ cargoTypeId: row.cargo_type_id, cargoWeightTons: opts.tons }],
|
||
cargoFreeText: "IT corridor wheat",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Book a PER_ITEM break-bulk line (automobiles / machinery from
|
||
* seed-bulk-items.sql). Wagon demand is the greater of the per-item floor and
|
||
* the tonnage math — which is exactly what these scenarios pin down.
|
||
*/
|
||
export async function bookBulkItems(opts: {
|
||
contractId: string;
|
||
cargoCode: "E2E_IMP_AUTO" | "E2E_IMP_MACHINE";
|
||
items: number;
|
||
tons: number;
|
||
scheduledDate?: string;
|
||
hazardousQuantity?: number;
|
||
reeferQuantity?: number;
|
||
as?: string;
|
||
}) {
|
||
const [row] = await db<{ cargo_type_id: string }>(
|
||
`SELECT t.id AS cargo_type_id FROM freight.cargo_types t WHERE t.code = $1`,
|
||
[opts.cargoCode],
|
||
);
|
||
if (!row) throw new Error(`cargo type ${opts.cargoCode} not seeded`);
|
||
return api(opts.as ?? superAdmin, "post", `/api/contracts/${opts.contractId}/bookings`, {
|
||
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
|
||
bulkLines: [
|
||
{
|
||
cargoTypeId: row.cargo_type_id,
|
||
itemCount: opts.items,
|
||
cargoWeightTons: opts.tons,
|
||
...(opts.hazardousQuantity != null ? { hazardousQuantity: opts.hazardousQuantity } : {}),
|
||
...(opts.reeferQuantity != null ? { reeferQuantity: opts.reeferQuantity } : {}),
|
||
},
|
||
],
|
||
cargoFreeText: `IT break-bulk ${opts.cargoCode}`,
|
||
});
|
||
}
|
||
|
||
/** Book PER_ITEM cargo and walk it to the pool. */
|
||
export async function bookBulkItemsReady(opts: {
|
||
contractId: string;
|
||
cargoCode: "E2E_IMP_AUTO" | "E2E_IMP_MACHINE";
|
||
items: number;
|
||
tons: number;
|
||
scheduledDate: string;
|
||
}): Promise<string> {
|
||
const res = await bookBulkItems(opts);
|
||
if (res.status > 201) {
|
||
throw new Error(`break-bulk booking rejected: ${res.status} ${JSON.stringify(res.body)}`);
|
||
}
|
||
const booking = await bookingFor(opts.contractId);
|
||
await clearBooking(booking.id, opts.scheduledDate, superAdmin);
|
||
await acceptOperation(booking.id);
|
||
return booking.id;
|
||
}
|
||
|
||
/**
|
||
* Type-integrity check for mixed trains: every wagon slot under the booking is
|
||
* of ONE expected type (containers → NW5, bulk → CW4) and the count matches.
|
||
* No wheat on a flat wagon, no box in a gondola.
|
||
*/
|
||
export async function expectWagonType(bookingId: string, code: string, wagons: number) {
|
||
const row = await poll<{ code: string; n: string }>(
|
||
`booking ${bookingId} rides ${wagons}× ${code}`,
|
||
`SELECT wt.code, count(*)::text AS n
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL
|
||
GROUP BY wt.code`,
|
||
[bookingId],
|
||
(r) => r?.code === code && Number(r?.n) === wagons,
|
||
{ attempts: 20 },
|
||
);
|
||
const [kinds] = await db<{ k: string }>(
|
||
`SELECT count(DISTINCT tsw.wagon_type_id)::text AS k
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||
WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
if (Number(kinds.k) !== 1) {
|
||
throw new Error(`booking ${bookingId} rides ${kinds.k} wagon types, expected 1`);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
/** Book + walk the clearance gate + ops accept, ending FULLY_EXECUTED in the pool. */
|
||
export async function bookBulkReady(opts: {
|
||
contractId: string;
|
||
tons: number;
|
||
scheduledDate: string;
|
||
mode?: "import" | "export";
|
||
/** Path B — walk the phased chain instead of the one-shot finalize. */
|
||
customs?: boolean;
|
||
}): Promise<string> {
|
||
const res = await bookBulk(opts);
|
||
if (res.status > 201) {
|
||
throw new Error(`bulk booking rejected: ${res.status} ${JSON.stringify(res.body)}`);
|
||
}
|
||
const booking = await bookingFor(opts.contractId);
|
||
if (opts.customs && opts.mode === "export") {
|
||
await clearBookingPhasedCustomsExport(booking.id, opts.scheduledDate);
|
||
} else if (opts.customs) {
|
||
await clearBookingPhasedCustoms(booking.id, opts.scheduledDate);
|
||
} else {
|
||
await clearBooking(booking.id, opts.scheduledDate, superAdmin);
|
||
}
|
||
if (opts.mode === "export") await acceptExport(booking.id);
|
||
else await acceptOperation(booking.id);
|
||
return booking.id;
|
||
}
|
||
|
||
/**
|
||
* Book, walk the clearance gate, and expect the shipment-day request to be
|
||
* REFUSED.
|
||
*
|
||
* The window and export-space gates no longer sit on booking creation — the
|
||
* booking is born in the clearance gate and the day is only validated when the
|
||
* customer picks it (`clearance/proceed` → requestOperation,
|
||
* contract-booking.service.ts). So "rejected at submission" now means rejected
|
||
* at the day request; creation itself succeeds.
|
||
*/
|
||
export async function expectDayRefused(opts: {
|
||
contractId: string;
|
||
tons: number;
|
||
scheduledDate: string;
|
||
}): Promise<{ status: number; body: unknown }> {
|
||
const res = await bookBulk(opts);
|
||
if (res.status > 201) return { status: res.status, body: res.body };
|
||
|
||
const booking = await bookingFor(opts.contractId);
|
||
await upload(superAdmin, `/api/bookings/${booking.id}/clearance/documents`, DOC_FIXTURE, "custom_e2e");
|
||
await apiOk(superAdmin, "post", `/api/bookings/${booking.id}/clearance/review`, {
|
||
fileKey: "custom_e2e",
|
||
status: "APPROVED",
|
||
});
|
||
await apiOk(superAdmin, "post", `/api/bookings/${booking.id}/clearance/finalize`);
|
||
const proceed = await api(superAdmin, "post", `/api/bookings/${booking.id}/clearance/proceed`, {
|
||
scheduledDate: opts.scheduledDate,
|
||
});
|
||
return { status: proceed.status, body: proceed.body };
|
||
}
|
||
|
||
/** Ops accepts an EXPORT operation request — FCFS: the accept itself reserves. */
|
||
export async function acceptExport(bookingId: string): Promise<void> {
|
||
await apiOk(opsStaff, "post", `/api/bookings/${bookingId}/operation/review`, {
|
||
decision: "ACCEPT",
|
||
});
|
||
await pollBookingStatus(bookingId, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 15);
|
||
}
|
||
|
||
/**
|
||
* Walk a customs (Path B) IMPORT booking through the PHASED clearance chain.
|
||
*
|
||
* `clearance/finalize` refuses these outright ("General customs bookings use
|
||
* phased clearance…") — the Cypress suite documents that gap and leaves its
|
||
* customs bookings failing. The real chain, GL Ethiopia and GL Djibouti in
|
||
* turn, is:
|
||
* docs upload → review APPROVED → request transit assignee → assign it →
|
||
* customs declaration → duty advice (none due) → transit permit →
|
||
* finalize pre-clearance → delivery order (DO_COLLECTED → ready for
|
||
* operation) → customer picks the shipment day.
|
||
*/
|
||
export async function clearBookingPhasedCustoms(
|
||
bookingId: string,
|
||
scheduledDate: string,
|
||
): Promise<void> {
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e");
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, {
|
||
fileKey: "custom_e2e",
|
||
status: "APPROVED",
|
||
});
|
||
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/transit-assignee/request`, {
|
||
note: "it",
|
||
});
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/transit-assignee/assign`, {
|
||
transitAgentId: await ensureTransitAgent(),
|
||
});
|
||
|
||
// GL ET sends a priced draft first; the customer accepts it before the real
|
||
// declaration may be filed (DRAFT_DECLARATION_SENT gates pre-clearance).
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/draft-declaration`, DOC_FIXTURE, "files", {
|
||
price: "1000",
|
||
currency: "ETB",
|
||
});
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/draft-declaration/accept`);
|
||
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/declaration`, DOC_FIXTURE, "declaration");
|
||
// No duty due — the slip step is then skipped by the workflow itself.
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/duty`, DOC_FIXTURE, "attachment", {
|
||
dutyRequired: "false",
|
||
});
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/transit-permit`, DOC_FIXTURE, "transit_permit");
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/finalize-pre-clearance`);
|
||
|
||
const vesselArrival = new Date(Date.now() - 24 * 3_600_000).toISOString().slice(0, 10);
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/delivery-order`, DOC_FIXTURE, "file", {
|
||
vesselArrivalDate: vesselArrival,
|
||
doCollectedDate: vesselArrival,
|
||
});
|
||
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate });
|
||
await pollBookingStatus(bookingId, "OPERATION_REQUEST_PENDING", 20);
|
||
}
|
||
|
||
/**
|
||
* EXPORT half of the phased customs chain: no transit assignee and no duty —
|
||
* GL ET files the declaration, GL DJ secures the Release Order (which must
|
||
* lead the vessel departure by the configured minimum), and that release is
|
||
* what unlocks the shipment day.
|
||
*/
|
||
export async function clearBookingPhasedCustomsExport(
|
||
bookingId: string,
|
||
scheduledDate: string,
|
||
): Promise<void> {
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e");
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, {
|
||
fileKey: "custom_e2e",
|
||
status: "APPROVED",
|
||
});
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/declaration`, DOC_FIXTURE, "declaration");
|
||
|
||
const vesselDeparture = new Date(Date.now() + 7 * 24 * 3_600_000).toISOString().slice(0, 10);
|
||
const ro = await upload(
|
||
superAdmin,
|
||
`/api/bookings/${bookingId}/clearance/release-order`,
|
||
DOC_FIXTURE,
|
||
"file",
|
||
{ vesselDepartureDate: vesselDeparture },
|
||
);
|
||
if (ro.status > 201) {
|
||
throw new Error(`release order → ${ro.status}: ${JSON.stringify(ro.body)}`);
|
||
}
|
||
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate });
|
||
await pollBookingStatus(bookingId, "OPERATION_REQUEST_PENDING", 20);
|
||
}
|
||
|
||
/** One reusable transit officer — the roster is empty in a fresh stack. */
|
||
export async function ensureTransitAgent(): Promise<string> {
|
||
const [existing] = await db<{ id: string }>(
|
||
`SELECT id FROM freight.transit_agents
|
||
WHERE is_active AND deleted_at IS NULL AND valid_from <= now() AND valid_to >= now()
|
||
LIMIT 1`,
|
||
);
|
||
if (existing) return existing.id;
|
||
const [created] = await db<{ id: string }>(
|
||
`INSERT INTO freight.transit_agents (name, valid_from, valid_to, is_active)
|
||
VALUES ('IT Transit Officer', now() - interval '1 day', now() + interval '1 year', true)
|
||
RETURNING id`,
|
||
);
|
||
return created.id;
|
||
}
|
||
|
||
/** DOMESTIC bookings have no shipment day — finalize alone lands FULLY_EXECUTED. */
|
||
export async function clearIntercityBooking(bookingId: string): Promise<void> {
|
||
await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e");
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, {
|
||
fileKey: "custom_e2e",
|
||
status: "APPROVED",
|
||
});
|
||
await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/finalize`);
|
||
await pollBookingStatus(bookingId, "FULLY_EXECUTED", 15);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// batch engine controls
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Batch fill reserves by priority DESC — order 1 = first pick. */
|
||
export function setPriority(bookingId: string, order: number) {
|
||
return db(`UPDATE freight.bookings SET priority_score = $2 WHERE id = $1`, [
|
||
bookingId,
|
||
1000 - order,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Push the pay window out for the bookings a test intends to PAY.
|
||
*
|
||
* A reservation's deadline is ~60s in this stack. Paying through the real
|
||
* gateway (initiate → provider → signed callback → broker → settle) plus the
|
||
* allocation wait costs more than that when several bookings pay in sequence,
|
||
* so on a loaded stack the last hold expires before its payment lands and the
|
||
* engine strands it ("re-placed stranded PAID booking …"). Widening the window
|
||
* is arrange, not assertion: the tests that are ABOUT expiry never call this.
|
||
*/
|
||
export async function extendPayWindow(
|
||
scheduleId: string,
|
||
bookingIds: string[],
|
||
minutes = 30,
|
||
): Promise<void> {
|
||
await db(
|
||
`UPDATE freight.bookings SET payment_deadline = now() + ($2 || ' minutes')::interval
|
||
WHERE id = ANY($1::uuid[])`,
|
||
[bookingIds, String(minutes)],
|
||
);
|
||
await db(
|
||
`UPDATE freight.train_schedules
|
||
SET payment_phase_ends_at = GREATEST(
|
||
COALESCE(payment_phase_ends_at, now()),
|
||
now() + ($2 || ' minutes')::interval)
|
||
WHERE id = $1`,
|
||
[scheduleId, String(minutes)],
|
||
);
|
||
}
|
||
|
||
/** End the payment phase now — the tick settles (allocate paid / expire unpaid). */
|
||
export function endPaymentPhase(scheduleId: string) {
|
||
return db(
|
||
`UPDATE freight.train_schedules
|
||
SET payment_phase_ends_at = now() - interval '1 second'
|
||
WHERE id = $1 AND window_phase = 'PAYMENT'`,
|
||
[scheduleId],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Push a reservation's pay deadline an hour into the past and wait for the
|
||
* engine to expire it.
|
||
*
|
||
* An hour, not a second: promoting a waiting booking extends the payment phase
|
||
* (extendPaymentPhaseForTopUp), and a deadline only just behind `now()` can end
|
||
* up on the wrong side of that move. Expiry also requires the payment service
|
||
* to answer the reconcile-before-expire question — which it really does here.
|
||
*/
|
||
export async function forceReservationExpiry(bookingId: string): Promise<void> {
|
||
await db(
|
||
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 hour' WHERE id = $1`,
|
||
[bookingId],
|
||
);
|
||
await pollBookingStatus(bookingId, "EXPIRED", 40);
|
||
}
|
||
|
||
/**
|
||
* Allocation is driven by the schedule's settle tick, not by the payment
|
||
* response — on a busy stack (full-suite run) that tick can be several cycles
|
||
* behind, so wait generously rather than flake.
|
||
*/
|
||
export function pollAllocations(bookingId: string, minWagons = 1) {
|
||
return poll<{ n: string }>(
|
||
`booking ${bookingId} wagon allocations >= ${minWagons}`,
|
||
`SELECT count(*)::text AS n FROM freight.wagon_booking_allocations
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[bookingId],
|
||
(row) => Number(row?.n ?? 0) >= minWagons,
|
||
{ attempts: 45, intervalMs: 3000 },
|
||
);
|
||
}
|
||
|
||
/** Distinct wagon slots committed to a schedule — the consist occupancy. */
|
||
export async function allocatedWagons(scheduleId: string): Promise<number> {
|
||
const [row] = await db<{ n: string }>(
|
||
`SELECT count(DISTINCT wba.train_set_wagon_id)::text AS n
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||
[scheduleId],
|
||
);
|
||
return Number(row.n);
|
||
}
|
||
|
||
/** The open partial-split offer the batch made a booking that did not fit whole. */
|
||
export function pollPartialOffer(bookingId: string) {
|
||
return poll<{ status: string; offered_wagons: number }>(
|
||
`booking ${bookingId} partial offer`,
|
||
`SELECT status, offered_wagons FROM freight.booking_batch_offers
|
||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||
ORDER BY created_at DESC LIMIT 1`,
|
||
[bookingId],
|
||
(row) => row?.status === "OFFERED",
|
||
{ attempts: 15 },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Wait for the payment phase to settle out.
|
||
*
|
||
* Only a FULL train rests at DONE. A day that ends under-filled CONCLUDES and
|
||
* REOPENS — window_phase goes back to OPEN on the next cycle — so waiting for
|
||
* DONE on an under-filled train waits forever.
|
||
*/
|
||
export const pollCycleConcluded = (scheduleId: string) =>
|
||
pollWindow(scheduleId, (s) => s.window_phase !== "PAYMENT", "payment phase concluded");
|
||
|
||
export function pollWindow(scheduleId: string, check: (row: ScheduleRow) => boolean, label: string) {
|
||
return poll<ScheduleRow>(
|
||
`schedule ${scheduleId} ${label}`,
|
||
`SELECT ${SCHEDULE_COLS} FROM freight.train_schedules ts WHERE ts.id = $1`,
|
||
[scheduleId],
|
||
(row) => !!row && check(row),
|
||
{ attempts: 40 },
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// paying through the REAL gateway
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Settle a reservation the way production does: initiate on the invoice
|
||
* (freight → payment service → provider), then have the gateway call back.
|
||
*
|
||
* This replaces the Cypress suite's staff `mark-paid` shortcut everywhere —
|
||
* mark-paid skips `booking.invoice.paid`, so it never applies a pending split
|
||
* offer and never proves the payment seam. Uses the staff initiate endpoint
|
||
* (`/api/payments/initiate`) because these fixtures are booked on the
|
||
* customer's behalf and have no portal user of their own.
|
||
*/
|
||
export async function payViaGateway(
|
||
bookingId: string,
|
||
opts: { method?: string; expectStatus?: string[] } = {},
|
||
): Promise<void> {
|
||
const invoice = await invoiceForBooking(bookingId);
|
||
const res = await api(opsStaff, "post", "/api/payments/initiate", {
|
||
invoiceId: invoice.id,
|
||
method: opts.method ?? "CBE_BIRR",
|
||
platform: "web",
|
||
});
|
||
if (res.status > 201) {
|
||
throw new Error(`initiate for ${bookingId} → ${res.status}: ${JSON.stringify(res.body)}`);
|
||
}
|
||
const intent = await gatewayIntent(bookingId);
|
||
const hook = await gateway.webhook({ merchantOrderId: intent.merchant_order_id });
|
||
if (hook.body?.delivered !== 200) {
|
||
throw new Error(`webhook delivery failed: ${JSON.stringify(hook.body)}`);
|
||
}
|
||
await poll(
|
||
`invoice ${invoice.invoice_number} PAID`,
|
||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||
[invoice.id],
|
||
(row) => (row as { status?: string })?.status === "PAID",
|
||
{ attempts: 30 },
|
||
);
|
||
await pollBookingStatus(bookingId, opts.expectStatus ?? ["PAID", "IN_TRANSIT", "ARRIVED"], 30);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// train journey + customs tail
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const gatePassGranted = (scheduleId: string) =>
|
||
apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/import-djibouti/gatepass-granted`);
|
||
|
||
export const finalizeSchedule = (scheduleId: string) =>
|
||
apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/finalize`);
|
||
|
||
export const dispatchSchedule = (scheduleId: string) =>
|
||
apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/dispatch`);
|
||
|
||
export const recordCheckpoint = (scheduleId: string, sequenceNo: number, kind: string) =>
|
||
apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/checkpoints`, {
|
||
sequenceNo,
|
||
kind,
|
||
});
|
||
|
||
/** Run the corridor: four PASSED checkpoints, then ARRIVED at the terminal. */
|
||
export async function runCorridor(scheduleId: string): Promise<void> {
|
||
for (const seq of [1, 2, 3, 4]) await recordCheckpoint(scheduleId, seq, "PASSED");
|
||
await recordCheckpoint(scheduleId, 5, "ARRIVED");
|
||
await pollWindow(scheduleId, (s) => s.status === "ARRIVED", "ARRIVED");
|
||
}
|
||
|
||
export const uploadT1 = (bookingId: string) =>
|
||
upload(superAdmin, `/api/contracts/bookings/${bookingId}/t1-documents`, DOC_FIXTURE);
|
||
|
||
export const uploadTransportDocument = (bookingId: string) =>
|
||
upload(superAdmin, `/api/contracts/bookings/${bookingId}/transport-document`, DOC_FIXTURE);
|
||
|
||
export const closeT1 = (bookingId: string) =>
|
||
apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/t1-close`);
|
||
|
||
/** Post-arrival import tail: T1 close → risk → second duty → final invoice. */
|
||
export async function runImportCustomsTail(bookingId: string): Promise<void> {
|
||
await closeT1(bookingId);
|
||
await apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/risk`, {
|
||
riskLevel: "GREEN",
|
||
});
|
||
await upload(superAdmin, `/api/contracts/bookings/${bookingId}/second-duty`, DOC_FIXTURE, "attachment", {
|
||
dutyRequired: "false",
|
||
});
|
||
await upload(superAdmin, `/api/contracts/bookings/${bookingId}/final-invoice`, DOC_FIXTURE, "file", {
|
||
amount: "1000",
|
||
description: "it final invoice",
|
||
});
|
||
// Issue → approve → customer pays → GL confirms. The slip is rejected while
|
||
// the invoice is still a draft.
|
||
await apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/final-invoice/approve`);
|
||
const slip = await upload(
|
||
superAdmin,
|
||
`/api/contracts/bookings/${bookingId}/final-invoice-slip`,
|
||
DOC_FIXTURE,
|
||
"file",
|
||
);
|
||
if (slip.status > 201) {
|
||
throw new Error(`final invoice slip → ${slip.status}: ${JSON.stringify(slip.body)}`);
|
||
}
|
||
await apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/final-invoice/confirm`);
|
||
await completeMilestone(bookingId, "IMPORT_RELEASE_GRANTED");
|
||
await completeMilestone(bookingId, "IMPORT_PROCESS_COMPLETED");
|
||
}
|
||
|
||
export const completeMilestone = (bookingId: string, code: string) =>
|
||
apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/milestones/${code}/complete`, {
|
||
note: "it",
|
||
});
|
||
|
||
export function expectMilestoneDone(bookingId: string, code: string) {
|
||
return poll<{ n: string }>(
|
||
`booking ${bookingId} milestone ${code}`,
|
||
`SELECT count(*)::text AS n FROM freight.clearance_milestones
|
||
WHERE booking_id = $1 AND milestone_code = $2 AND status = 'COMPLETED' AND deleted_at IS NULL`,
|
||
[bookingId, code],
|
||
(row) => Number(row?.n ?? 0) > 0,
|
||
{ attempts: 15 },
|
||
);
|
||
}
|
||
|
||
export async function milestoneCount(bookingId: string, code: string): Promise<number> {
|
||
const [row] = await db<{ n: string }>(
|
||
`SELECT count(*)::text AS n FROM freight.clearance_milestones
|
||
WHERE booking_id = $1 AND milestone_code = $2 AND status = 'COMPLETED' AND deleted_at IS NULL`,
|
||
[bookingId, code],
|
||
);
|
||
return Number(row.n);
|
||
}
|
||
|
||
export const acceptIntercityOnto = (scheduleId: string, bookingId: string) =>
|
||
apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/intercity/accept`, {
|
||
bookingIds: [bookingId],
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GROUP 1 — wagon arithmetic on the 53-wagon BUILT container train
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** The Group 1 built train's consist size — see seed-g1-train.sql. */
|
||
export const G1_WAGONS = 53;
|
||
export const G1_TRAIN = "TRN-G1-1";
|
||
|
||
/** seed-government.sql — the kind='government' company POST /api/bookings needs. */
|
||
export const GOV_COMPANY_ID = "0a1b0001-0000-4000-8000-000000000001";
|
||
export const GOV_PROFILE_ID = "0b1c0001-0000-4000-8000-000000000001";
|
||
|
||
/**
|
||
* Wagons a container booking needs: two 20ft share a wagon, a 40ft takes one.
|
||
* An odd 20ft count still costs a whole wagon — and `assert20ftPairable`
|
||
* refuses to submit one — so every scenario keeps 20ft quantities even.
|
||
*/
|
||
export const containerWagons = (twenty = 0, forty = 0) => Math.ceil(twenty / 2) + forty;
|
||
|
||
/**
|
||
* Schedule the BUILT train rather than a locomotive pair.
|
||
*
|
||
* `maxWagonsPerTrain` is not a real cap on the pair path: syncScheduleMaxWagons
|
||
* recomputes max_wagons from locomotive length (54 on this corridor) every fill
|
||
* pass. A built train's coupled consist wins outright
|
||
* (`physicalWagons ?? capacityLimits(loco)`), so the 53 wagons seed-g1-train.sql
|
||
* couples ARE the capacity — which is the number Group 1's arithmetic is
|
||
* written in. The count is asserted here: a 52-wagon consist would shift every
|
||
* scenario by a slot and fail far from the cause.
|
||
*/
|
||
export async function createBuiltTrainSchedule(opts: {
|
||
departure: Date;
|
||
trainCode?: string;
|
||
wagons?: number;
|
||
}): Promise<ScheduleRow> {
|
||
const trainCode = opts.trainCode ?? G1_TRAIN;
|
||
if (!(await findSchedule(opts.departure))) {
|
||
const route = await routeId();
|
||
if (!route) throw new Error(`route ${ORIGIN}→${DEST} missing`);
|
||
const [train] = await db<{ id: string }>(
|
||
`SELECT id FROM freight.trains WHERE code = $1`,
|
||
[trainCode],
|
||
);
|
||
if (!train) throw new Error(`built train ${trainCode} missing — seed-g1-train.sql`);
|
||
await apiOk(opsStaff, "post", "/api/train-scheduling/container/schedules", {
|
||
routeId: route,
|
||
scheduleDate: opts.departure.toISOString(),
|
||
trainId: train.id,
|
||
});
|
||
}
|
||
const schedule = await findSchedule(opts.departure);
|
||
if (!schedule) throw new Error("built-train schedule was not created");
|
||
const wagons = opts.wagons ?? G1_WAGONS;
|
||
if (Number(schedule.max_wagons) !== wagons) {
|
||
throw new Error(
|
||
`${trainCode} scheduled with max_wagons=${schedule.max_wagons}, expected the ${wagons}-wagon consist`,
|
||
);
|
||
}
|
||
return schedule;
|
||
}
|
||
|
||
/** Book containers on the customer's behalf and walk them into the day pool. */
|
||
export async function bookContainersReady(opts: {
|
||
contractId: string;
|
||
runStamp: string;
|
||
isoSeed: number;
|
||
twenty?: number;
|
||
forty?: number;
|
||
scheduledDate: string;
|
||
vgmTons?: number;
|
||
/** Path B contract — walk the phased chain instead of the one-shot finalize. */
|
||
customs?: boolean;
|
||
}): Promise<string> {
|
||
const res = await bookContainers({ ...opts, as: superAdmin });
|
||
if (res.status > 201) {
|
||
throw new Error(`container booking rejected: ${res.status} ${JSON.stringify(res.body)}`);
|
||
}
|
||
const booking = await bookingFor(opts.contractId);
|
||
if (opts.customs) await clearBookingPhasedCustoms(booking.id, opts.scheduledDate);
|
||
else await clearBooking(booking.id, opts.scheduledDate, superAdmin);
|
||
await acceptOperation(booking.id);
|
||
return booking.id;
|
||
}
|
||
|
||
/** Containers still on a booking — the number a split REDUCES. */
|
||
export async function containerCount(bookingId: string): Promise<number> {
|
||
const [row] = await db<{ q: string | null }>(
|
||
`SELECT sum(quantity)::text AS q FROM freight.booking_container
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
return Number(row?.q ?? 0);
|
||
}
|
||
|
||
/** Assert the batch never raised a partial offer — the whole-or-nothing cases. */
|
||
export async function expectNoPartialOffer(bookingId: string, label = bookingId): Promise<void> {
|
||
const [row] = await db<{ n: string }>(
|
||
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
if (Number(row.n) !== 0) throw new Error(`${label} was offered a split (${row.n} offer rows)`);
|
||
}
|
||
|
||
/**
|
||
* Every container on the train mapped to a wagon slot, with a real number on it.
|
||
*
|
||
* Wagon counts alone cannot catch a half-done allocation: a booking whose
|
||
* wagons were reserved but whose units were never placed still reads as a full
|
||
* train. The units live in `wagon_allocation_container_items` — one row per
|
||
* container, and the marshalling sheet is generated from that column.
|
||
*/
|
||
export async function expectContainersPlaced(scheduleId: string, containers: number) {
|
||
const placed = poll<{ n: string }>(
|
||
`${containers} containers mapped to wagon slots on ${scheduleId}`,
|
||
`SELECT count(*)::text AS n
|
||
FROM freight.wagon_allocation_container_items ci
|
||
JOIN freight.wagon_booking_allocations wba ON wba.id = ci.wagon_booking_allocation_id
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||
[scheduleId],
|
||
(row) => Number(row?.n ?? 0) === containers,
|
||
{ attempts: 25 },
|
||
);
|
||
await placed;
|
||
const [blank] = await db<{ n: string }>(
|
||
`SELECT count(*)::text AS n
|
||
FROM freight.wagon_allocation_container_items ci
|
||
JOIN freight.wagon_booking_allocations wba ON wba.id = ci.wagon_booking_allocation_id
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL
|
||
AND (ci.container_number IS NULL OR ci.container_number = '')`,
|
||
[scheduleId],
|
||
);
|
||
if (Number(blank.n) !== 0) {
|
||
throw new Error(`${blank.n} slot(s) placed without a container number`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* GROSS tonnage riding a schedule — cargo PLUS the tare of every wagon it
|
||
* occupies, because a locomotive hauls the wagon as well as what is in it.
|
||
*
|
||
* `allocated_weight_tons` holds the CARGO alone, so the tare of each allocated
|
||
* wagon type is added here. Reading the column raw understates a loaded consist
|
||
* by 22.4 T a wagon and makes a weight-bound train look half empty.
|
||
*/
|
||
export async function allocatedGrossTons(scheduleId: string): Promise<number> {
|
||
const [row] = await db<{ tons: string | null }>(
|
||
`SELECT COALESCE(sum(wba.allocated_weight_tons + wt.tare_weight_tons), 0)::text AS tons
|
||
FROM freight.wagon_booking_allocations wba
|
||
JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||
JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||
JOIN freight.train_schedule_bookings tsb
|
||
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
|
||
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
|
||
[scheduleId],
|
||
);
|
||
return Number(row.tons ?? 0);
|
||
}
|
||
|
||
/** Wagons a single booking currently holds. */
|
||
export async function wagonAllocationCount(bookingId: string): Promise<number> {
|
||
const [row] = await db<{ n: string }>(
|
||
`SELECT count(*)::text AS n FROM freight.wagon_booking_allocations
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
[bookingId],
|
||
);
|
||
return Number(row.n);
|
||
}
|
||
|
||
/** Bookings still linked to a schedule — the seats actually held. */
|
||
export async function linkedBookings(scheduleId: string): Promise<number> {
|
||
const [row] = await db<{ n: string }>(
|
||
`SELECT count(*)::text AS n FROM freight.train_schedule_bookings
|
||
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
|
||
[scheduleId],
|
||
);
|
||
return Number(row.n);
|
||
}
|
||
|
||
/** Invoices still payable against a booking — an expiry must leave none. */
|
||
export async function livePayableInvoices(bookingId: string): Promise<number> {
|
||
const [row] = await db<{ n: string }>(
|
||
`SELECT count(*)::text AS n FROM freight.invoices
|
||
WHERE source = 'booking' AND source_id = $1 AND deleted_at IS NULL
|
||
AND paid_at IS NULL
|
||
-- ::text because the enum has no VOID member; comparing the label
|
||
-- directly makes Postgres reject the whole query.
|
||
AND status::text NOT IN ('EXPIRED','CANCELLED','VOID')`,
|
||
[bookingId],
|
||
);
|
||
return Number(row.n);
|
||
}
|
||
|
||
/** The portal's own availability question for a shipment day. */
|
||
export async function dayAvailability(bookingId: string, day: string) {
|
||
const res = await apiOk(
|
||
superAdmin,
|
||
"get",
|
||
`/api/bookings/${bookingId}/day-availability?date=${day}`,
|
||
);
|
||
const body = res.body as {
|
||
trainsForDay?: boolean;
|
||
freeWagons?: number;
|
||
data?: { trainsForDay?: boolean; freeWagons?: number };
|
||
};
|
||
return body.data ?? body;
|
||
}
|
||
|
||
/**
|
||
* An EXPIRED booking is recoverable without re-approval: the CONTRACT is still
|
||
* executed, so the customer can rebook a later day. Asserting the contract (not
|
||
* just the booking) is the point — a bug that retired it would strand them.
|
||
*/
|
||
export async function expectContractStillBookable(bookingId: string): Promise<void> {
|
||
const booking = await bookingRow(bookingId);
|
||
const [contract] = await db<{ status: string }>(
|
||
`SELECT status FROM freight.contracts WHERE id = $1`,
|
||
[booking.contract_id],
|
||
);
|
||
if (!["FULLY_EXECUTED", "CONTRACT_ACTIVE"].includes(contract.status)) {
|
||
throw new Error(`contract of ${bookingId} is ${contract.status}, no longer bookable`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Let a partial offer lapse instead of paying it. The offer dies with the
|
||
* booking's pay deadline, so pushing the deadline back is what the wall clock
|
||
* would do — and the settle tick then expires the booking WHOLE.
|
||
*/
|
||
export async function forceOfferLapse(bookingId: string): Promise<void> {
|
||
await db(
|
||
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 hour' WHERE id = $1`,
|
||
[bookingId],
|
||
);
|
||
await pollBookingStatus(bookingId, "EXPIRED", 40);
|
||
}
|
||
|
||
/**
|
||
* A government booking, created the way the product creates one: staff POST
|
||
* /api/bookings against the seeded kind='government' company (never the
|
||
* contract wizard), then `government-expedite` promotes it to PAID/Eligible —
|
||
* it rides without paying.
|
||
*/
|
||
export async function createGovernmentBooking(opts: {
|
||
forty: number;
|
||
vgmTons?: number;
|
||
scheduledDate?: string;
|
||
}): Promise<string> {
|
||
const vgm = opts.vgmTons ?? 10;
|
||
const [origin] = await db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [ORIGIN]);
|
||
const [dest] = await db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [DEST]);
|
||
const [service] = await db<{ id: string }>(
|
||
`SELECT id FROM freight.service_types ORDER BY created_at LIMIT 1`,
|
||
);
|
||
const [ctype] = await db<{ id: string }>(
|
||
`SELECT id FROM freight.container_types WHERE size_ft = 40 AND is_active LIMIT 1`,
|
||
);
|
||
await apiOk(superAdmin, "post", "/api/bookings", {
|
||
isGovernment: true,
|
||
companyId: GOV_COMPANY_ID,
|
||
companyProfileId: GOV_PROFILE_ID,
|
||
contractType: "NEW",
|
||
serviceTypeId: service.id,
|
||
equipmentReturn: "WITHOUT_RETURN",
|
||
originYardId: origin.id,
|
||
destinationYardId: dest.id,
|
||
tradeDirection: "IMPORT",
|
||
freightType: "CONTAINER",
|
||
containers: [{ containerTypeId: ctype.id, quantity: opts.forty, vgmPerUnitTons: vgm }],
|
||
cargoTotalWeightVgm: opts.forty * vgm,
|
||
paymentCurrency: "USD",
|
||
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
|
||
});
|
||
const [row] = await db<{ id: string }>(
|
||
`SELECT id FROM freight.bookings
|
||
WHERE company_id = $1 AND is_government = true AND deleted_at IS NULL
|
||
ORDER BY created_at DESC LIMIT 1`,
|
||
[GOV_COMPANY_ID],
|
||
);
|
||
if (!row) throw new Error("government booking was not created");
|
||
return row.id;
|
||
}
|
||
|
||
/** Idempotent retry path — create() already expedites, this re-asserts it. */
|
||
export const governmentExpedite = (bookingId: string) =>
|
||
apiOk(superAdmin, "post", `/api/bookings/${bookingId}/government-expedite`);
|
||
|
||
/** Staff pin: fillSchedule's pool is keyed on `booking.train_schedule_id`. */
|
||
export const pinToSchedule = (bookingId: string, scheduleId: string) =>
|
||
db(`UPDATE freight.bookings SET train_schedule_id = $1 WHERE id = $2`, [scheduleId, bookingId]);
|
||
|
||
/** Staff "run batch" button — a fill pass on demand, no phase change. */
|
||
export const triggerBatchRun = (scheduleId: string) =>
|
||
apiOk(superAdmin, "post", `/api/train-scheduling/schedules/${scheduleId}/run-batch`);
|
||
|
||
/** Wipe this suite's leftover government bookings so "newest" is unambiguous. */
|
||
export async function releaseGovernmentBookings(): Promise<void> {
|
||
await db(
|
||
`UPDATE freight.wagon_booking_allocations wba SET deleted_at = now()
|
||
FROM freight.bookings b
|
||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL AND b.company_id = $1`,
|
||
[GOV_COMPANY_ID],
|
||
);
|
||
await db(
|
||
`DELETE FROM freight.train_schedule_bookings tsb USING freight.bookings b
|
||
WHERE tsb.booking_id = b.id AND b.company_id = $1`,
|
||
[GOV_COMPANY_ID],
|
||
);
|
||
await db(
|
||
`UPDATE freight.bookings SET status = 'CANCELLED', train_schedule_id = NULL,
|
||
scheduling_status = 'NOT_SCHEDULED', deleted_at = now()
|
||
WHERE company_id = $1 AND deleted_at IS NULL`,
|
||
[GOV_COMPANY_ID],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A priority band, created and dropped by the test that needs it. The rule
|
||
* engine scores each booking additively from the ACTIVE bands, so a band left
|
||
* behind would re-rank every later file's pool.
|
||
*/
|
||
export async function createPriorityConfig(body: {
|
||
type: "WAGON" | "CURRENCY" | "CUSTOMS";
|
||
label: string;
|
||
currency?: "ETB" | "USD";
|
||
minWagonCount: number;
|
||
maxWagonCount: number;
|
||
scorePoints: number;
|
||
}): Promise<string> {
|
||
// Through the API, not SQL: bands must be contiguous from 1 per type
|
||
// (assertNoRangeCollision) and points are capped at 50 — a hand-inserted row
|
||
// could violate both and score a booking the product never would.
|
||
const res = await apiOk(superAdmin, "post", "/api/priority-configs", {
|
||
...body,
|
||
isActive: true,
|
||
});
|
||
const id = (res.body?.data?.id ?? res.body?.id) as string | undefined;
|
||
if (!id) throw new Error(`priority config not created: ${JSON.stringify(res.body)}`);
|
||
return id;
|
||
}
|
||
|
||
export const dropPriorityConfig = (id: string) =>
|
||
api(superAdmin, "delete", `/api/priority-configs/${id}`);
|
||
|
||
export async function bookingRow(bookingId: string): Promise<BookingRow> {
|
||
const [row] = await db<BookingRow>(
|
||
`SELECT b.id, b.reference, b.status, b.scheduling_status, b.train_schedule_id,
|
||
b.payment_deadline, b.contract_id, b.is_split, b.pre_split_quantities,
|
||
b.wagons_required, b.cargo_total_weight_vgm, b.priority_score,
|
||
b.is_government
|
||
FROM freight.bookings b WHERE b.id = $1`,
|
||
[bookingId],
|
||
);
|
||
return row;
|
||
}
|