change contrat creation

This commit is contained in:
Marshal
2026-07-24 13:26:19 +00:00
parent 668b5e1c9d
commit 286390a3cf
23 changed files with 2439 additions and 56 deletions

View File

@@ -0,0 +1,134 @@
/**
* BOOKING — cancel lifecycle + the commit gate:
*
* `POST /bookings/:id/cancel` is only legal for a booking that has not yet
* been committed to a train (DRAFT … OPERATION_REQUEST_PENDING). This spec
* proves both sides end to end, which the unit tests never do:
*
* an un-accepted booking (OPERATION_REQUEST_PENDING) cancels → CANCELLED,
* and its open payable invoice is expired (no dangling payable)
* cancelling it again is rejected (status CANCELLED not allowed)
* a PAID + allocated booking CANNOT be cancelled — the guard blocks it.
* (There is deliberately no cancel-after-allocation / refund path; a
* committed booking leaves only by EXPIRE, never customer cancel.)
*
* EXPORT bulk corridor (FCFS: accept reserves immediately, mark-paid allocates).
* Retries off — sequential steps of one journey.
*/
import {
acceptExport,
apiPost,
bookBulk,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceWindowOpen,
markPaid,
pollAllocations,
resetCorridorDay,
seedImportContract,
superAdmin,
withBooking,
withExportSchedule,
} from "./import-utils";
const DEPARTURE = departureAt(6);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
function seedBulkExport(suffix: string) {
seedImportContract({
suffix,
reference: `CTR-IMP-${stamp}-${suffix}`,
freight: "BULK",
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
/** Fire cancel with a reason; caller decides whether to fail on non-2xx. */
function cancel(bookingId: string, failOnStatusCode = true) {
return apiPost(
superAdmin,
`/api/bookings/${bookingId}/cancel`,
{ reason: `E2E cancel ${stamp}` },
failOnStatusCode,
);
}
describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
["CXA", "CXB"].forEach(seedBulkExport);
});
it("operations opens the export corridor + a D+6 CW4 train", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => {
bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY });
// Fresh non-customs export booking sits at OPERATION_REQUEST_PENDING.
withBooking("CXA", (b) => {
expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING");
cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201]));
});
withBooking("CXA", (b) => {
expect(b.status, "now CANCELLED").to.eq("CANCELLED");
// No open payable invoice may survive a cancel.
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.invoices
WHERE source = 'booking' AND source_id = $1
AND status NOT IN ('EXPIRED','CANCELLED','PAID')
AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) => expect(Number(rows[0].n), "no open invoice left").to.eq(0));
// Cancelling a CANCELLED booking is rejected by the status guard.
cancel(b.id, false).then((res) => {
expect(res.status, "re-cancel rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include("Cannot perform this action");
expect(JSON.stringify(res.body)).to.include("CANCELLED");
});
});
});
it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => {
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
acceptExport("CXB");
markPaid("CXB");
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons
withBooking("CXB", (b) => {
expect(b.status, "committed + paid").to.eq("PAID");
cancel(b.id, false).then((res) => {
expect(res.status, "cancel blocked after allocation").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include("Cannot perform this action");
expect(JSON.stringify(res.body)).to.include("PAID");
});
});
// The block is real: the booking still rides, wagons still allocated.
withBooking("CXB", (b) => expect(b.status, "still PAID").to.eq("PAID"));
pollAllocations("CXB", 10);
});
});
export {};

View File

@@ -0,0 +1,141 @@
/**
* BOOKING — clearance document QUERY (reject) + re-upload lifecycle:
*
* Every other spec that touches clearance only ever APPROVES documents
* (`clearGeneralBooking` in import-utils.ts). This spec drives the other
* branch of `reviewDocument` — QUERIED — which no spec exercises:
*
* querying a document with no note is rejected (a note is required)
* querying with a note succeeds: reviewStatus → QUERIED, the note is
* stored, and a CHANGES_REQUESTED review note is recorded on the booking
* re-uploading the SAME document resets its review to PENDING (the
* customer's fix clears the query — submitClearanceDocuments always
* resets reviewed docs back to PENDING on re-upload)
* GL re-reviews it APPROVED → finalize succeeds → CLEARANCE_READY
*
* GENERAL contract, per-booking clearance gate (AWAITING_DOCUMENTS at
* creation, before ops ever sees it) — same fixture shape as
* clearGeneralBooking, with an ad-hoc document. Retries off.
*/
import {
apiPost,
bookContainers,
db,
departureAt,
eatDayStr,
glUpload,
seedImportContract,
superAdmin,
withBooking,
} from "./import-utils";
const stamp = String(Date.now());
// Per-booking clearance (Path A) skips the booking-window gate entirely, so
// this date never needs a real schedule behind it — just a binding day string.
const BOOKING_DAY = eatDayStr(departureAt(33));
interface ReviewRow {
status: string;
note: string | null;
}
function reviewFor(bookingId: string, fn: (row: ReviewRow) => void) {
db<ReviewRow>(
`SELECT status, note FROM freight.booking_document_review
WHERE booking_id = $1 AND file_key = 'custom_e2e'`,
[bookingId],
).then(({ rows }) => {
expect(rows, "review row for custom_e2e").to.have.length(1);
fn(rows[0]);
});
}
describe("booking: clearance document query (reject) + re-upload lifecycle", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedImportContract({ suffix: "CQ1", reference: `CTR-IMP-${stamp}-CQ1`, kind: "GENERAL" });
});
it("books under the GENERAL contract — starts at the clearance gate", () => {
bookContainers({
suffix: "CQ1",
runStamp: stamp,
isoSeed: 0,
forty: 1,
scheduledDate: BOOKING_DAY,
});
withBooking("CQ1", (b) =>
expect(b.status, "AWAITING_DOCUMENTS").to.eq("AWAITING_DOCUMENTS"),
);
});
it("uploads a document; querying it with no note is rejected", () => {
withBooking("CQ1", (b) => {
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
apiPost(
superAdmin,
`/api/bookings/${b.id}/clearance/review`,
{ fileKey: "custom_e2e", status: "QUERIED" },
false,
).then((res) => {
expect(res.status, "note required").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include("A note is required");
});
});
});
it("querying with a note succeeds — reviewStatus QUERIED, note stored, review note recorded", () => {
withBooking("CQ1", (b) => {
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
fileKey: "custom_e2e",
status: "QUERIED",
note: "E2E: wrong document, please re-upload the correct one",
}).then((res) => expect(res.status, "queried").to.be.oneOf([200, 201]));
reviewFor(b.id, (row) => {
expect(row.status, "QUERIED").to.eq("QUERIED");
expect(row.note, "note stored").to.include("wrong document");
});
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.booking_review_note
WHERE booking_id = $1 AND type = 'CHANGES_REQUESTED'`,
[b.id],
).then(({ rows }) =>
expect(Number(rows[0].n), "queried review note recorded").to.be.at.least(1),
);
// Still under review — the query doesn't advance the booking status.
withBooking("CQ1", (fresh) =>
expect(fresh.status, "still DOCUMENTS_UNDER_REVIEW").to.eq("DOCUMENTS_UNDER_REVIEW"),
);
});
});
it("re-uploading resets the review to PENDING; GL approves it and finalize succeeds", () => {
withBooking("CQ1", (b) => {
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
reviewFor(b.id, (row) => expect(row.status, "reset to PENDING").to.eq("PENDING"));
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
}).then((res) => expect(res.status, "approved").to.be.oneOf([200, 201]));
reviewFor(b.id, (row) => expect(row.status, "APPROVED").to.eq("APPROVED"));
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`).then((res) =>
expect(res.status, "finalized").to.be.oneOf([200, 201]),
);
});
withBooking("CQ1", (b) =>
expect(b.status, "CLEARANCE_READY").to.eq("CLEARANCE_READY"),
);
});
});
export {};

View File

@@ -2,7 +2,7 @@
* Contract creation → finalization, spanning portal + backoffice:
*
* 1. portal (user@gmail.com, company seeded active by seed-company.sql):
* wizard → GENERAL / Import / Container / 20ft → submit + approve quote
* wizard → GENERAL / Import / Container (both sizes) → submit + approve quote
* 2. backoffice marketer: "Accept for approval" (validity) + approve the
* LINE_STAFF step
* 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY
@@ -62,12 +62,10 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route.
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
// 40ft (no size picker — just an info card) and the cargo description moved
// to booking time, so the scope select plus the route is all this step needs.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type(
"E2E electronics shipment scope",
);
cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port");
cy.contains("button", "Continue").click({ force: true });
@@ -80,7 +78,10 @@ describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
cy.contains("button", "Approve & submit").click();
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
// `exist`, not `be.visible`: the status badge sits inside the list's
// horizontally-scrolling container, so Cypress reports it as clipped by an
// overflow parent. The DB assertion below is the authoritative check.
cy.contains("Submitted", { timeout: 15000 }).should("exist");
dbContract().then(({ rows }) => {
expect(rows, "contract row").to.have.length(1);

View File

@@ -0,0 +1,197 @@
/**
* CONTRACT — rejection / send-back (import & export contracts):
*
* Covered elsewhere only for the INTERCITY contract wizard (rejection +
* reason + resubmit). This spec drives the CONTRACT-level reject/
* request-changes/approval-chain guards directly via the API against a
* plain IMPORT container contract, something no other spec exercises:
*
* SUBMITTED → staff reject → REJECTED (terminal, reason recorded)
* SUBMITTED → staff request-changes → CHANGES_REQUESTED
* SUBMITTED → staff accept → PENDING_APPROVAL (chain instantiated) →
* reject the first step straight to the customer (no returnToStepId)
* → REJECTED
* SUBMITTED → accept → approve step 1 → reject step 2 WITH
* returnToStepId=step1 (send-back) → step 1 and 2 both reset to
* PENDING, contract stays PENDING_APPROVAL (alive, not rejected)
* guard: a rejection can only return to an EARLIER step
*
* Contracts are seeded directly at SUBMITTED (skipping the wizard — that
* journey is covered by contract-lifecycle.cy.ts) so this spec is pure API.
* Retries off — each `it` is an independent contract's own short journey.
*/
import { apiPost, companyTin, db, DEST, ORIGIN, superAdmin } from "./import-utils";
const stamp = String(Date.now());
let seq = 0;
/** A plain IMPORT/CONTAINER/ONE_TIME contract seeded straight to SUBMITTED —
* no cargo scope (so instantiateApprovalSteps resolves the non-director
* chain), no pricing (reject/request-changes/accept never read it). */
function seedSubmittedContract(): Cypress.Chainable<string> {
const reference = `CTR-REJ-${stamp}-${seq++}`;
return db<{ id: string }>(
`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, 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 p.type = 'importer' THEN 0 ELSE 1 END
LIMIT 1),
'ONE_TIME', 'IMPORT', 'CONTAINER',
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
'ETB', false, 'NOT_APPLICABLE', 'SUBMITTED', 'E2E contract-reject fixture'
FROM freight.companies comp WHERE comp.tin = $2
RETURNING id
)
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 = $3
JOIN freight.yards d ON d.code = $4
RETURNING contract_id AS id`,
[reference, companyTin, ORIGIN, DEST],
).then(({ rows }) => {
expect(rows, "seeded SUBMITTED contract").to.have.length(1);
return cy.wrap(rows[0].id, { log: false });
});
}
interface ContractRow {
status: string;
}
function contractStatus(id: string, fn: (row: ContractRow) => void) {
db<ContractRow>(`SELECT status FROM freight.contracts WHERE id = $1`, [id]).then(
({ rows }) => {
expect(rows, "contract row").to.have.length(1);
fn(rows[0]);
},
);
}
interface StepRow {
id: string;
step_order: number;
status: string;
}
function approvalSteps(contractId: string, fn: (rows: StepRow[]) => void) {
db<StepRow>(
`SELECT id, step_order, status FROM freight.contract_approval_steps
WHERE contract_id = $1 AND deleted_at IS NULL ORDER BY step_order ASC`,
[contractId],
).then(({ rows }) => fn(rows));
}
describe("contract: rejection and approval-chain send-back", { retries: 0 }, () => {
it("SUBMITTED → staff reject → REJECTED, reason recorded", () => {
seedSubmittedContract().then((id) => {
apiPost(superAdmin, `/api/contracts/${id}/staff/reject`, {
reason: "E2E: missing documents",
}).then((res) => expect(res.status, "rejected").to.be.oneOf([200, 201]));
contractStatus(id, (c) => expect(c.status, "REJECTED").to.eq("REJECTED"));
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.contract_review_notes
WHERE contract_id = $1 AND note_type = 'REJECTION'`,
[id],
).then(({ rows }) => expect(Number(rows[0].n), "rejection note recorded").to.be.at.least(1));
// Terminal: rejecting again is refused by the status guard.
apiPost(
superAdmin,
`/api/contracts/${id}/staff/reject`,
{ reason: "again" },
false,
).then((res) => expect(res.status, "re-reject refused").to.be.within(400, 422));
});
});
it("SUBMITTED → staff request-changes → CHANGES_REQUESTED", () => {
seedSubmittedContract().then((id) => {
apiPost(superAdmin, `/api/contracts/${id}/staff/request-changes`, {
note: "E2E: please add the missing cargo description",
}).then((res) => expect(res.status, "changes requested").to.be.oneOf([200, 201]));
contractStatus(id, (c) =>
expect(c.status, "CHANGES_REQUESTED").to.eq("CHANGES_REQUESTED"),
);
// request-changes without a note is refused.
seedSubmittedContract().then((id2) => {
apiPost(
superAdmin,
`/api/contracts/${id2}/staff/request-changes`,
{},
false,
).then((res) => expect(res.status, "note required").to.be.within(400, 422));
});
});
});
it("accept → PENDING_APPROVAL; rejecting the first step straight to the customer → REJECTED", () => {
seedSubmittedContract().then((id) => {
apiPost(superAdmin, `/api/contracts/${id}/staff/accept`, { validityDays: 365 }).then(
(res) => expect(res.status, "accepted into chain").to.be.oneOf([200, 201]),
);
approvalSteps(id, (steps) => {
expect(steps.length, "chain has at least one step").to.be.at.least(1);
const step1 = steps[0];
apiPost(superAdmin, `/api/contracts/${id}/approval-steps/${step1.id}/reject`, {
reason: "E2E: rejected to customer",
}).then((res) => expect(res.status, "rejected to customer").to.be.oneOf([200, 201]));
});
contractStatus(id, (c) => expect(c.status, "REJECTED (terminal)").to.eq("REJECTED"));
});
});
it("send-back: reject step 2 back to step 1 — both reset PENDING, contract stays alive", () => {
seedSubmittedContract().then((id) => {
apiPost(superAdmin, `/api/contracts/${id}/staff/accept`, { validityDays: 365 });
approvalSteps(id, (steps) => {
expect(steps.length, "chain has at least 2 steps for a send-back").to.be.at.least(2);
const [step1, step2] = steps;
apiPost(superAdmin, `/api/contracts/${id}/approval-steps/${step1.id}/approve`).then(
(res) => expect(res.status, "step 1 approved").to.be.oneOf([200, 201]),
);
// Guard: a rejection can only return to an EARLIER step — step2 → step2 rejected.
apiPost(
superAdmin,
`/api/contracts/${id}/approval-steps/${step2.id}/reject`,
{ reason: "bad", returnToStepId: step2.id },
false,
).then((res) => {
expect(res.status, "same-step return rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include("EARLIER step");
});
apiPost(superAdmin, `/api/contracts/${id}/approval-steps/${step2.id}/reject`, {
reason: "E2E: send back for a fix",
returnToStepId: step1.id,
}).then((res) => expect(res.status, "sent back to step 1").to.be.oneOf([200, 201]));
});
contractStatus(id, (c) =>
expect(c.status, "still PENDING_APPROVAL (alive)").to.eq("PENDING_APPROVAL"),
);
approvalSteps(id, (steps) => {
expect(steps[0].status, "step 1 reset to PENDING").to.eq("PENDING");
expect(steps[1].status, "step 2 reset to PENDING").to.eq("PENDING");
});
});
});
});
export {};

View File

@@ -387,10 +387,9 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
// Container contracts auto-cover both 20ft & 40ft (no size picker) and the
// cargo description moved to booking time — the scope select is enough here.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
cy.contains("button", "Continue").click({ force: true });

View File

@@ -0,0 +1,227 @@
/**
* FLEET — wagon transfer between yards (two-person OCC queue):
*
* A requester files a COUNT-ONLY transfer request (source yard + type + how
* many, never the specific wagons); OCC later hand-picks the physical wagons
* and fulfils it. This spec drives the real endpoints and asserts the guards
* the unit tests never exercise end to end:
*
* create: same-yard rejected, empty-source rejected, over-available capped
* fulfil: wrong count rejected, off-source wagon rejected, exact picks move
* the wagons (current_yard_id flips + wagon_movements ledger written)
* cancel: only PENDING cancellable; a cancelled request can't be re-cancelled
* or fulfilled
*
* Uses the corridor seed's CW4 export pocket at KALITY (120 available) and the
* empty mid-corridor E2E_AWASH yard as the destination. Retries off.
*/
import { apiPost, db, superAdmin } from "./import-utils";
const stamp = String(Date.now());
const REASON_A = `WTR-${stamp}-A`; // fulfil flow
const REASON_B = `WTR-${stamp}-B`; // cancel flow
interface Ids {
kality: string;
awash: string;
dire: string;
cw4: string;
}
/** Resolve the yard + wagon-type ids this spec works with (one query). */
function ids(fn: (v: Ids) => void) {
db<Ids>(
`SELECT
(SELECT id FROM freight.yards WHERE code = 'KALITY') AS kality,
(SELECT id FROM freight.yards WHERE code = 'E2E_AWASH') AS awash,
(SELECT id FROM freight.yards WHERE code = 'DIRE_DAWA') AS dire,
(SELECT id FROM freight.wagon_types WHERE code = 'CW4') AS cw4`,
).then(({ rows }) => fn(rows[0]));
}
/** N AVAILABLE CW4 wagon ids sitting in a yard, lowest number first. */
function availableCw4(yardCode: string, limit: number) {
return db<{ id: string }>(
`SELECT w.id
FROM freight.wagons w
JOIN freight.yards y ON y.id = w.current_yard_id AND y.code = $1
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id AND wt.code = 'CW4'
WHERE w.status = 'AVAILABLE' AND w.deleted_at IS NULL
ORDER BY w.wagon_number
LIMIT $2`,
[yardCode, limit],
);
}
/** The single request this run filed under `reason`. */
function requestByReason(reason: string) {
return db<{ id: string; status: string; quantity: number }>(
`SELECT id, status, quantity FROM freight.wagon_transfer_requests
WHERE reason = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[reason],
);
}
describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
});
it("files a count-only request — same-yard and empty-source are rejected", () => {
ids(({ kality, awash, cw4 }) => {
// Valid: KALITY has plenty of available CW4.
apiPost(superAdmin, "/api/wagon-transfer-requests", {
fromYardId: kality,
toYardId: awash,
wagonTypeId: cw4,
quantity: 3,
reason: REASON_A,
}).then((res) => {
expect(res.status, "request filed").to.be.oneOf([200, 201]);
});
// Guard: source must differ from destination.
apiPost(
superAdmin,
"/api/wagon-transfer-requests",
{ fromYardId: kality, toYardId: kality, wagonTypeId: cw4, quantity: 1, reason: "x" },
false,
).then((res) => {
expect(res.status, "same-yard rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("must be different");
});
// Guard: cannot request wagons a yard doesn't have (E2E_AWASH holds none).
apiPost(
superAdmin,
"/api/wagon-transfer-requests",
{ fromYardId: awash, toYardId: kality, wagonTypeId: cw4, quantity: 1, reason: "x" },
false,
).then((res) => {
expect(res.status, "empty-source rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.match(/no available wagons/i);
});
// The filed request is PENDING for exactly 3.
requestByReason(REASON_A).then(({ rows }) => {
expect(rows, "request row").to.have.length(1);
expect(rows[0].status, "PENDING").to.eq("PENDING");
expect(Number(rows[0].quantity), "quantity").to.eq(3);
});
});
});
it("fulfil rejects wrong count and off-source wagons, then moves the exact picks", () => {
ids(({ dire }) => {
requestByReason(REASON_A).then(({ rows }) => {
const reqId = rows[0].id;
// Wrong count: request is for 3, offer 2.
availableCw4("KALITY", 2).then(({ rows: two }) => {
apiPost(
superAdmin,
`/api/wagon-transfer-requests/${reqId}/fulfill`,
{ wagonIds: two.map((w) => w.id) },
false,
).then((res) => {
expect(res.status, "wrong count rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("Select exactly 3");
});
});
// Off-source: 3 wagons but one sits at DIRE_DAWA, not the source yard.
availableCw4("KALITY", 2).then(({ rows: k2 }) => {
availableCw4("DIRE_DAWA", 1).then(({ rows: d1 }) => {
apiPost(
superAdmin,
`/api/wagon-transfer-requests/${reqId}/fulfill`,
{ wagonIds: [...k2, ...d1].map((w) => w.id) },
false,
).then((res) => {
expect(res.status, "off-source rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("not in the source yard");
});
});
});
// Exact 3 valid KALITY picks — the move runs.
availableCw4("KALITY", 3).then(({ rows: three }) => {
const picked = three.map((w) => w.id);
apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/fulfill`, {
wagonIds: picked,
}).then((res) => {
expect(res.status, "fulfilled").to.be.oneOf([200, 201]);
});
// Request FULFILLED; the 3 wagons now sit at E2E_AWASH; each move is
// written to the wagon_movements ledger stamped with this request.
requestByReason(REASON_A).then(({ rows: r }) =>
expect(r[0].status, "FULFILLED").to.eq("FULFILLED"),
);
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagons w
JOIN freight.yards y ON y.id = w.current_yard_id AND y.code = 'E2E_AWASH'
WHERE w.id = ANY($1::uuid[])`,
[picked],
).then(({ rows: at }) => expect(Number(at[0].n), "3 moved to AWASH").to.eq(3));
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagon_movements
WHERE transfer_request_id = $1 AND wagon_id = ANY($2::uuid[])`,
[reqId, picked],
).then(({ rows: mv }) =>
expect(Number(mv[0].n), "3 movement rows").to.eq(3),
);
});
});
});
});
it("only PENDING requests cancel — a cancelled one can't be re-cancelled or fulfilled", () => {
ids(({ kality, awash, cw4 }) => {
apiPost(superAdmin, "/api/wagon-transfer-requests", {
fromYardId: kality,
toYardId: awash,
wagonTypeId: cw4,
quantity: 2,
reason: REASON_B,
}).then((res) => expect(res.status).to.be.oneOf([200, 201]));
requestByReason(REASON_B).then(({ rows }) => {
const reqId = rows[0].id;
// Withdraw it.
apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/cancel`).then((res) =>
expect(res.status, "cancelled").to.be.oneOf([200, 201]),
);
requestByReason(REASON_B).then(({ rows: r }) =>
expect(r[0].status, "CANCELLED").to.eq("CANCELLED"),
);
// Re-cancel is a conflict.
apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/cancel`, {}, false).then(
(res) => {
expect(res.status, "re-cancel conflict").to.eq(409);
expect(JSON.stringify(res.body)).to.match(/only pending/i);
},
);
// Fulfilling a cancelled request is a conflict too.
availableCw4("KALITY", 2).then(({ rows: two }) => {
apiPost(
superAdmin,
`/api/wagon-transfer-requests/${reqId}/fulfill`,
{ wagonIds: two.map((w) => w.id) },
false,
).then((res) => {
expect(res.status, "fulfil-after-cancel conflict").to.eq(409);
expect(JSON.stringify(res.body)).to.include("already cancelled");
});
});
});
});
});
});
export {};

View File

@@ -0,0 +1,254 @@
/**
* BATCH — government preemption:
*
* A government booking that fits nowhere on an already-committed train
* displaces the LOWEST-priority commercial reservation first (never the
* higher-priority one), then allocates directly — no pay window, since
* government rides unpaid (see booking-batch.service.ts `preemptForGovernment`
* + `allocate(..., "gov")`).
*
* Setup: a 3-wagon import train. CGA (priority 1, higher) and CGB (priority
* 2, lower) each book one 40ft container and reserve via the normal batch —
* 2/3 wagons used, 1 free. A standalone government booking (2×40ft = 2
* wagons) then needs more than the 1 free slot: it doesn't fit, so the
* engine preempts CGB (the lower-priority reservation) to free the second
* wagon, then allocates the government booking. CGA is untouched throughout.
*
* Government bookings don't go through the customer contract wizard —
* they're created directly via `POST /bookings` (isGovernment: true, billed
* to a real government company/profile — see seed-government.sql) and
* promoted with `POST /bookings/:id/government-expedite`.
*
* Sequential steps of one scenario — retries off.
*/
import {
acceptOperation,
apiPost,
bookContainers,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
DEST,
eatDayStr,
ensureCorridorRoute,
forceWindowOpen,
ORIGIN,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
superAdmin,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(27);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const GOV_COMPANY_ID = "0a1b0001-0000-4000-8000-000000000001";
const GOV_PROFILE_ID = "0b1c0001-0000-4000-8000-000000000001";
function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
withSchedule(DEPARTURE, (s) => fn(s.id, s));
}
interface GovBookingRow {
id: string;
status: string;
scheduling_status: string;
is_government: boolean;
train_schedule_id: string | null;
}
/** This run's government booking — always re-queried, never trusted from a
* create-response shape (only one exists per run: newest for this gov company). */
function withGovBooking(fn: (b: GovBookingRow) => void) {
db<GovBookingRow>(
`SELECT id, status, scheduling_status, is_government, train_schedule_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],
).then(({ rows }) => {
expect(rows, "this run's government booking").to.have.length(1);
fn(rows[0]);
});
}
describe("batch: government booking preempts the lowest-priority commercial reservation", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-government.sql");
["CGA", "CGB"].forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix) }),
);
});
it("operations schedules the dedicated 3-wagon built train (TRN-GOV-1) — window forced open", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
// A loco-pair schedule's max_wagons is NOT a real cap — it gets recomputed
// from the locomotive's length every fill pass (54 for a standard loco),
// ignoring maxWagonsPerTrain. A BUILT train's coupled count IS the cap.
createImportSchedule({
departure: DEPARTURE,
trainCode: "TRN-GOV-1",
maxWagons: 3,
});
withScheduleId((id) => forceWindowOpen(id, 45));
});
it("CGA and CGB each book one 40ft and reserve — 2/3 wagons used, 1 free", () => {
bookContainers({
suffix: "CGA",
runStamp: stamp,
isoSeed: 0,
forty: 1,
scheduledDate: BOOKING_DAY,
});
acceptOperation("CGA");
bookContainers({
suffix: "CGB",
runStamp: stamp,
isoSeed: 10,
forty: 1,
scheduledDate: BOOKING_DAY,
});
acceptOperation("CGB");
setPriority("CGA", 1); // higher priority — must survive
setPriority("CGB", 2); // lower priority — the preemption target
withScheduleId((id) => {
closeBookingWindow(id);
completeDocReview(id);
});
withBooking("CGA", (b) =>
expect(b.status, "CGA reserved").to.be.oneOf(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
withBooking("CGB", (b) =>
expect(b.status, "CGB reserved").to.be.oneOf(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
});
it("a government booking (2×40ft) is created and expedited to PAID, then pinned to the train", () => {
db<{ id: string }>(
`SELECT id FROM freight.yards WHERE code = $1`,
[ORIGIN],
).then(({ rows: o }) => {
db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [DEST]).then(
({ rows: d }) => {
db<{ id: string }>(
`SELECT id FROM freight.service_types ORDER BY created_at LIMIT 1`,
).then(({ rows: st }) => {
db<{ id: string }>(
`SELECT id FROM freight.container_types WHERE size_ft = 40 AND is_active LIMIT 1`,
).then(({ rows: ct }) => {
apiPost(superAdmin, "/api/bookings", {
isGovernment: true,
companyId: GOV_COMPANY_ID,
companyProfileId: GOV_PROFILE_ID,
contractType: "NEW",
serviceTypeId: st[0].id,
equipmentReturn: "WITHOUT_RETURN",
originYardId: o[0].id,
destinationYardId: d[0].id,
tradeDirection: "IMPORT",
freightType: "CONTAINER",
containers: [{ containerTypeId: ct[0].id, quantity: 2, vgmPerUnitTons: 10 }],
cargoTotalWeightVgm: 20,
paymentCurrency: "USD",
}).then((res) => {
expect(res.status, "government booking created").to.be.oneOf([200, 201]);
withGovBooking((created) => {
apiPost(
superAdmin,
`/api/bookings/${created.id}/government-expedite`,
).then((expRes) => {
expect(expRes.status, "expedited").to.be.oneOf([200, 201]);
});
});
withGovBooking((b) => {
expect(b.status, "PAID after expedite").to.eq("PAID");
expect(b.is_government, "flagged government").to.eq(true);
// Staff pin onto this schedule (the customer-facing OPEN-window
// pin can't be used here — the window already closed when
// completeDocReview ran). Mirrors a staff manual assign.
withScheduleId((scheduleId) => {
db(
`UPDATE freight.bookings SET train_schedule_id = $1 WHERE id = $2`,
[scheduleId, b.id],
);
});
});
});
});
});
},
);
});
});
it("run-batch: the government booking preempts CGB (lower priority), CGA is untouched", () => {
withScheduleId((scheduleId) => {
apiPost(superAdmin, `/api/train-scheduling/schedules/${scheduleId}/run-batch`).then(
(res) => expect(res.status, "run-batch").to.be.oneOf([200, 201]),
);
});
// CGB displaced: EXPIRED, back to ELIGIBLE, no pay window, invoice expired.
pollBookingStatus("CGB", "EXPIRED");
withBooking("CGB", (b) => {
expect(b.scheduling_status, "CGB back to ELIGIBLE").to.eq("ELIGIBLE");
expect(b.payment_deadline, "CGB pay window cleared").to.be.null;
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.invoices
WHERE source = 'booking' AND source_id = $1
AND status NOT IN ('EXPIRED','CANCELLED','PAID') AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) => expect(Number(rows[0].n), "CGB invoice expired").to.eq(0));
});
// CGA survives untouched — still reserved on the same train.
withScheduleId((scheduleId) => {
withBooking("CGA", (b) => {
expect(b.status, "CGA still reserved").to.be.oneOf([
"SELECTED_FOR_BATCH",
"AWAITING_PAYMENT",
]);
expect(b.train_schedule_id, "CGA still on this train").to.eq(scheduleId);
});
});
// Government booking allocated: SCHEDULED + linked via the schedule-bookings
// table (allocate() never sets the train_schedule_id column for gov — only
// the link row — so the pin from the previous step is what carries it).
withGovBooking((b) => {
expect(b.scheduling_status, "gov SCHEDULED").to.eq("SCHEDULED");
withScheduleId((scheduleId) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
[b.id, scheduleId],
).then(({ rows }) => expect(Number(rows[0].n), "gov linked to schedule").to.eq(1));
});
pollDb<{ n: string }>(
"gov booking allocated 2 wagons",
`SELECT count(*) AS n FROM freight.wagon_booking_allocations WHERE booking_id = $1`,
[b.id],
(row) => Number(row?.n ?? 0) >= 2,
);
});
});
});
export {};

View File

@@ -67,6 +67,34 @@ export function apiPost(
);
}
export function apiGet(email: string, path: string, failOnStatusCode = true) {
return tokenFor(email).then((token) =>
cy.request({
method: "GET",
url: `${apiUrl()}${path}`,
headers: { Authorization: `Bearer ${token}` },
failOnStatusCode,
}),
);
}
export function apiPatch(
email: string,
path: string,
body?: unknown,
failOnStatusCode = true,
) {
return tokenFor(email).then((token) =>
cy.request({
method: "PATCH",
url: `${apiUrl()}${path}`,
headers: { Authorization: `Bearer ${token}` },
body: body ?? {},
failOnStatusCode,
}),
);
}
/** Poll a 1-row query until `check` passes (10s window tick ⇒ 3s cadence). */
export function pollDb<T = Row>(
label: string,

View File

@@ -149,12 +149,10 @@ function createIntercityContract() {
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route (Ethiopian yards only for intercity).
// Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container
// contracts auto-cover both 20ft & 40ft (no size picker) and the cargo
// description moved to booking time — the scope select is enough here.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type(
"E2E intercity electronics between Ethiopian yards",
);
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
cy.mantineSelect(/^Destination Yard/, DEST_YARD);
cy.contains("button", "Continue").click({ force: true });

View File

@@ -0,0 +1,229 @@
/**
* RULES & CONFIG — rate lifecycle + live rate-change mid-window:
*
* A LIVE rate is what pricing charges right now, so it is never edited in
* place (rates.service.ts `applyApprovedUpdate` doc comment) — an edit is
* filed as a change request and only takes effect once approved. This spec
* drives the full lifecycle end to end, something the unit tests never do:
*
* create (DRAFT) → duplicate-combo rejected → submit → PENDING_APPROVAL
* → a non-DRAFT edit is rejected → approve → LIVE, visible on /rates/live
* re-approving an already-LIVE rate is rejected
* a rate-change-request proposing the SAME value is rejected ("nothing
* changed"); a real change files PENDING; a second concurrent change is
* rejected ("already has a change awaiting approval")
* approving the change updates the LIVE row'S VALUE IN PLACE (same rate
* id, same row) — the "mid-window" live edit this whole flow protects
* re-approving the same (now-decided) change request is rejected
*
* BULK/ALWAYS/IMPORT rate on a yard pair the corridor seed never touches
* (DJIB_PORT → E2E_AWASH), so the duplicate-combo guard has a clean slate.
* Retries off — sequential steps of one rate's lifecycle.
*/
import { apiPost, db, superAdmin } from "./import-utils";
interface RateRow {
id: string;
status: string;
rate_value: string;
}
function rateByPattern() {
return db<RateRow>(
`SELECT r.id, r.status, r.rate_value
FROM freight.rates r
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'DJIB_PORT'
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'E2E_AWASH'
WHERE r.rate_type = 'BULK_IMPORT' AND r.rate_unit = 'PER_TON'
AND r.deleted_at IS NULL
ORDER BY r.created_at DESC LIMIT 1`,
).then(({ rows }) => {
expect(rows, "the test rate").to.have.length(1);
return rows[0];
});
}
interface ChangeRequestRow {
id: string;
status: string;
rate_id: string;
}
function changeRequestByRate(rateId: string) {
return db<ChangeRequestRow>(
`SELECT id, status, rate_id FROM freight.rate_change_requests
WHERE rate_id = $1 ORDER BY created_at DESC LIMIT 1`,
[rateId],
).then(({ rows }) => {
expect(rows, "change request for this rate").to.have.length(1);
return rows[0];
});
}
function yardId(code: string) {
return db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [code]).then(
({ rows }) => rows[0].id,
);
}
const RATE_BODY = (originYardId: string, destinationYardId: string, rateValue: number) => ({
appliesTo: "BULK",
trigger: "ALWAYS",
tradeDirection: "IMPORT",
originYardId,
destinationYardId,
rateValue,
rateUnit: "PER_TON",
});
describe("rules & config: rate lifecycle + live rate-change mid-window", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
// Soft-delete any leftover rate from a prior run of this spec — the
// duplicate-combo guard under test would otherwise reject THIS run's
// very first create against yesterday's row on a persistent DB.
db(
`UPDATE freight.rates r
SET deleted_at = now()
FROM freight.yards o, freight.yards d
WHERE r.origin_yard_id = o.id AND o.code = 'DJIB_PORT'
AND r.destination_yard_id = d.id AND d.code = 'E2E_AWASH'
AND r.rate_type = 'BULK_IMPORT' AND r.rate_unit = 'PER_TON'
AND r.deleted_at IS NULL`,
);
});
it("creates a DRAFT rate — a duplicate combination is rejected", () => {
yardId("DJIB_PORT").then((originYardId) => {
yardId("E2E_AWASH").then((destinationYardId) => {
apiPost(superAdmin, "/api/rates", RATE_BODY(originYardId, destinationYardId, 100)).then(
(res) => expect(res.status, "rate created").to.be.oneOf([200, 201]),
);
apiPost(
superAdmin,
"/api/rates",
RATE_BODY(originYardId, destinationYardId, 999),
false,
).then((res) => {
expect(res.status, "duplicate combo rejected").to.eq(409);
expect(JSON.stringify(res.body)).to.include("already exists on this route");
});
});
});
rateByPattern().then((r) => {
expect(r.status, "starts DRAFT").to.eq("DRAFT");
expect(Number(r.rate_value), "original value 100").to.eq(100);
});
});
it("submit → PENDING_APPROVAL; a non-DRAFT rate can't be edited directly", () => {
rateByPattern().then((r) => {
apiPost(superAdmin, `/api/rates/${r.id}/submit`).then((res) =>
expect(res.status, "submitted").to.be.oneOf([200, 201]),
);
});
rateByPattern().then((r) => {
expect(r.status, "PENDING_APPROVAL").to.eq("PENDING_APPROVAL");
apiPost(superAdmin, `/api/rates/${r.id}`, { rateValue: 150 }, false).then((res) => {
expect(res.status, "direct edit rejected").to.be.within(400, 422);
});
});
});
it("approve → LIVE, visible on /rates/live; re-approving is rejected", () => {
rateByPattern().then((r) => {
apiPost(superAdmin, `/api/rates/${r.id}/approve`).then((res) =>
expect(res.status, "approved").to.be.oneOf([200, 201]),
);
});
rateByPattern().then((r) => {
expect(r.status, "LIVE").to.eq("LIVE");
expect(Number(r.rate_value), "still 100 at approval").to.eq(100);
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.rates WHERE id = $1 AND status = 'LIVE'`,
[r.id],
).then(({ rows }) => expect(Number(rows[0].n), "present as LIVE").to.eq(1));
apiPost(superAdmin, `/api/rates/${r.id}/approve`, {}, false).then((res) => {
expect(res.status, "re-approve rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include("PENDING_APPROVAL");
});
});
});
it("a change proposing the SAME value is rejected — nothing changed", () => {
rateByPattern().then((r) => {
apiPost(
superAdmin,
"/api/rate-change-requests",
{ rateId: r.id, update: { rateValue: 100 } },
false,
).then((res) => {
expect(res.status, "no-op change rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("Nothing changed");
});
});
});
it("a real change files PENDING; a second concurrent change is rejected", () => {
rateByPattern().then((r) => {
apiPost(superAdmin, "/api/rate-change-requests", {
rateId: r.id,
update: { rateValue: 250 },
}).then((res) => expect(res.status, "change filed").to.be.oneOf([200, 201]));
apiPost(
superAdmin,
"/api/rate-change-requests",
{ rateId: r.id, update: { rateValue: 300 } },
false,
).then((res) => {
expect(res.status, "second concurrent change rejected").to.eq(409);
expect(JSON.stringify(res.body)).to.include("already has a change awaiting approval");
});
changeRequestByRate(r.id).then((cr) => {
expect(cr.status, "PENDING").to.eq("PENDING");
});
// The rate itself is untouched while the change is pending.
rateByPattern().then((live) =>
expect(Number(live.rate_value), "still 100 while pending").to.eq(100),
);
});
});
it("approving the change updates the LIVE row in place; re-approving it is rejected", () => {
rateByPattern().then((r) => {
changeRequestByRate(r.id).then((cr) => {
apiPost(superAdmin, `/api/rate-change-requests/${cr.id}/approve`, {}).then((res) =>
expect(res.status, "change approved").to.be.oneOf([200, 201]),
);
});
});
rateByPattern().then((r) => {
expect(r.status, "still LIVE (same row)").to.eq("LIVE");
expect(Number(r.rate_value), "value now 250").to.eq(250);
changeRequestByRate(r.id).then((cr) => {
expect(cr.status, "APPROVED").to.eq("APPROVED");
apiPost(superAdmin, `/api/rate-change-requests/${cr.id}/approve`, {}, false).then(
(res) => {
expect(res.status, "re-approve rejected").to.eq(409);
expect(JSON.stringify(res.body)).to.include("already approved");
},
);
});
});
});
});
export {};

View File

@@ -0,0 +1,142 @@
/**
* SCHEDULING — window-rule snapshot survives a global-rule edit:
*
* Each `train_schedules` row freezes the booking-window rule it was CREATED
* with, into 5 `rule_*` columns (migration 1920000000000). Editing the
* global rules must apply to FUTURE schedules only — an already-created
* schedule keeps its OWN frozen rule, so the batch board/customer window
* never redraws under an already-open train (see the
* schedule-window-rule-snapshot memory for the regression this protects:
* a global-rule edit used to redraw an open schedule's board as a
* synthetic grid that no longer matched the window the customer saw).
*
* schedule A is created under the CURRENT global rules → its snapshot
* matches them
* PATCH /global-rules to DIFFERENT windowOpenHour/windowDurationHours
* schedule A's snapshot is UNCHANGED (frozen) after the edit
* schedule B, created AFTER the edit, snapshots the NEW values
* global rules are restored at the end — a shared singleton every
* other spec in the suite reads
*
* Retries off — sequential steps against the shared global-rules singleton.
*/
import {
apiGet,
apiPatch,
createImportSchedule,
db,
departureAt,
ensureCorridorRoute,
forceWindowOpen,
resetCorridorDay,
superAdmin,
withSchedule,
} from "./import-utils";
const DAY_A = departureAt(30);
const DAY_B = departureAt(31);
interface GlobalRules {
windowOpenHour: number;
windowDurationHours: number;
}
interface SnapshotRow {
rule_window_open_hour: number;
rule_window_duration_hours: string;
}
function snapshotFor(departure: Date, fn: (row: SnapshotRow) => void) {
withSchedule(departure, (s) => {
db<SnapshotRow>(
`SELECT rule_window_open_hour, rule_window_duration_hours
FROM freight.train_schedules WHERE id = $1`,
[s.id],
).then(({ rows }) => {
expect(rows, "schedule snapshot row").to.have.length(1);
fn(rows[0]);
});
});
}
let original: GlobalRules;
let changed: GlobalRules;
describe("scheduling: window-rule snapshot is frozen against later global-rule edits", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
ensureCorridorRoute();
resetCorridorDay(DAY_A);
resetCorridorDay(DAY_B);
});
after(() => {
// Restore the shared global-rules singleton for every other spec.
if (original) apiPatch(superAdmin, "/api/train-scheduling/global-rules", original);
});
it("captures the current global rules, then creates schedule A under them", () => {
apiGet(superAdmin, "/api/train-scheduling/global-rules").then((res) => {
const body = res.body.data ?? res.body;
original = {
windowOpenHour: Number(body.windowOpenHour),
windowDurationHours: Number(body.windowDurationHours),
};
changed = {
windowOpenHour: (original.windowOpenHour + 5) % 24,
windowDurationHours: original.windowDurationHours >= 6 ? 2 : 8,
};
expect(changed.windowOpenHour, "changed open hour differs").to.not.eq(original.windowOpenHour);
expect(changed.windowDurationHours, "changed duration differs").to.not.eq(
original.windowDurationHours,
);
});
createImportSchedule({ departure: DAY_A, locoPair: ["LOCO-IMP-1", "LOCO-IMP-2"] });
// restampPendingWindows refreshes the snapshot for PRE_WINDOW schedules —
// the freeze only takes hold once a schedule is OPEN (see the
// schedule-window-rule-snapshot memory). Force it open now so the global-
// rule edit below lands on an already-frozen schedule, not a pending one.
withSchedule(DAY_A, (s) => forceWindowOpen(s.id, 45));
snapshotFor(DAY_A, (row) => {
expect(row.rule_window_open_hour, "A snapshots current open hour").to.eq(
original.windowOpenHour,
);
expect(Number(row.rule_window_duration_hours), "A snapshots current duration").to.eq(
original.windowDurationHours,
);
});
});
it("edits the global rules, creates schedule B — B snapshots the NEW values", () => {
apiPatch(superAdmin, "/api/train-scheduling/global-rules", changed).then((res) =>
expect(res.status, "global rules updated").to.be.oneOf([200, 201]),
);
createImportSchedule({ departure: DAY_B, locoPair: ["LOCO-IMP-3", "LOCO-IMP-4"] });
snapshotFor(DAY_B, (row) => {
expect(row.rule_window_open_hour, "B snapshots the NEW open hour").to.eq(
changed.windowOpenHour,
);
expect(Number(row.rule_window_duration_hours), "B snapshots the NEW duration").to.eq(
changed.windowDurationHours,
);
});
});
it("schedule A's snapshot is UNCHANGED — frozen against the later edit", () => {
snapshotFor(DAY_A, (row) => {
expect(row.rule_window_open_hour, "A still the ORIGINAL open hour").to.eq(
original.windowOpenHour,
);
expect(Number(row.rule_window_duration_hours), "A still the ORIGINAL duration").to.eq(
original.windowDurationHours,
);
});
});
});
export {};

View File

@@ -0,0 +1,191 @@
/**
* TRAIN-BUILDER — adjust-consist headroom guard:
*
* `POST /train-scheduling/schedules/:id/adjust-consist` permanently couples
* or trims a BUILT train's consist from a live schedule. This spec drives it
* against a dedicated small-capacity train (TRN-ADJ-1, see
* seed-adjust-consist.sql): 120T-pull locomotives with a 20T overage
* tolerance ⇒ a 140T pull cap, starting consist 4 × CW4 (99.2T tare).
*
* no wagons passed at all → rejected ("nothing to adjust")
* the same wagon in both add and remove → rejected
* removing a wagon that isn't part of this train → rejected
* adding ONE free spare (→ 124.0T) is within the 140T cap → SUCCEEDS,
* schedule.max_wagons follows the new consist size
* adding the SECOND spare too (→ 148.8T) breaches the 140T cap →
* REJECTED with the exact gross-weight-over-limit message
* removing a free (unallocated) wagon succeeds, consist shrinks back
*
* Sequential steps against one schedule — retries off.
*/
import {
apiPost,
createImportSchedule,
db,
departureAt,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
resetCorridorDay,
superAdmin,
} from "./import-utils";
const DEPARTURE = departureAt(9);
interface WagonRow {
id: string;
wagon_number: string;
train_id: string | null;
status: string;
}
function wagonByNumber(num: string) {
return db<WagonRow>(
`SELECT id, wagon_number, train_id, status FROM freight.wagons WHERE wagon_number = $1`,
[num],
).then(({ rows }) => {
expect(rows, `wagon ${num}`).to.have.length(1);
return rows[0];
});
}
interface ScheduleRow {
id: string;
max_wagons: number;
}
function adjTrainSchedule() {
return db<ScheduleRow>(
`SELECT ts.id, ts.max_wagons
FROM freight.train_schedules ts
JOIN freight.train_sets se ON se.id = ts.train_set_id
JOIN freight.trains t ON t.id = se.train_id AND t.code = 'TRN-ADJ-1'
WHERE ts.deleted_at IS NULL
ORDER BY ts.created_at DESC LIMIT 1`,
).then(({ rows }) => {
expect(rows, "TRN-ADJ-1 schedule").to.have.length(1);
return rows[0];
});
}
function adjustConsist(
scheduleId: string,
body: { addWagonIds?: string[]; removeWagonIds?: string[] },
failOnStatusCode = true,
) {
return apiPost(
superAdmin,
`/api/train-scheduling/schedules/${scheduleId}/adjust-consist`,
body,
failOnStatusCode,
);
}
describe("train-builder: adjust-consist headroom guard", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
cy.task("db:seedFile", "seed-adjust-consist.sql");
});
it("operations schedules the dedicated 4-wagon built train (TRN-ADJ-1)", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
trainCode: "TRN-ADJ-1",
maxWagons: 4,
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
});
it("rejects an empty adjustment and a wagon listed in both add and remove", () => {
adjTrainSchedule().then((s) => {
adjustConsist(s.id, {}, false).then((res) => {
expect(res.status, "nothing-to-adjust rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("Nothing to adjust");
});
wagonByNumber("WGN-ADJ-C1").then((w) => {
adjustConsist(s.id, { addWagonIds: [w.id], removeWagonIds: [w.id] }, false).then(
(res) => {
expect(res.status, "add+remove same wagon rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include(
"cannot be added and removed in the same adjustment",
);
},
);
});
});
});
it("rejects removing a wagon that isn't coupled to this train", () => {
adjTrainSchedule().then((s) => {
wagonByNumber("WGN-ADJ-S1").then((spare) => {
adjustConsist(s.id, { removeWagonIds: [spare.id] }, false).then((res) => {
expect(res.status, "not-part-of-train rejected").to.eq(404);
expect(JSON.stringify(res.body)).to.include("not coupled to train");
});
});
});
});
it("adds one spare within the 140T pull cap (99.2T + 24.8T = 124.0T) — succeeds", () => {
adjTrainSchedule().then((s) => {
wagonByNumber("WGN-ADJ-S1").then((spare) => {
adjustConsist(s.id, { addWagonIds: [spare.id] }).then((res) => {
expect(res.status, "add within headroom").to.be.oneOf([200, 201]);
});
});
});
// Consist grew to 5; the coupled spare now belongs to the train.
adjTrainSchedule().then((s) => expect(s.max_wagons, "max_wagons = 5").to.eq(5));
wagonByNumber("WGN-ADJ-S1").then((w) => {
db<{ code: string }>(
`SELECT t.code FROM freight.trains t WHERE t.id = $1`,
[w.train_id],
).then(({ rows }) => expect(rows[0]?.code, "S1 now on TRN-ADJ-1").to.eq("TRN-ADJ-1"));
});
});
it("adding the second spare breaches the 140T cap (124.0T + 24.8T = 148.8T) — rejected", () => {
adjTrainSchedule().then((s) => {
wagonByNumber("WGN-ADJ-S2").then((spare) => {
adjustConsist(s.id, { addWagonIds: [spare.id] }, false).then((res) => {
expect(res.status, "add over headroom rejected").to.eq(400);
const body = JSON.stringify(res.body);
expect(body).to.include("puts gross weight at 148.8T");
expect(body).to.include("over the locomotives' 140T limit incl. tolerance");
});
});
});
// Rejected add must not have moved anything: still 5 wagons, S2 still free.
adjTrainSchedule().then((s) => expect(s.max_wagons, "still 5").to.eq(5));
wagonByNumber("WGN-ADJ-S2").then((w) => {
expect(w.train_id, "S2 still uncoupled").to.be.null;
expect(w.status, "S2 still AVAILABLE").to.eq("AVAILABLE");
});
});
it("removes a free coupled wagon — consist shrinks and the wagon returns to the yard", () => {
adjTrainSchedule().then((s) => {
wagonByNumber("WGN-ADJ-C4").then((w) => {
adjustConsist(s.id, { removeWagonIds: [w.id] }).then((res) => {
expect(res.status, "remove free wagon").to.be.oneOf([200, 201]);
});
});
});
adjTrainSchedule().then((s) => expect(s.max_wagons, "max_wagons = 4").to.eq(4));
wagonByNumber("WGN-ADJ-C4").then((w) => {
expect(w.train_id, "C4 detached").to.be.null;
expect(w.status, "C4 back to AVAILABLE").to.eq("AVAILABLE");
});
});
});
export {};

View File

@@ -0,0 +1,83 @@
-- Arrange-data for flows/train_builder_adjust_consist.cy.ts. Idempotent.
-- Run AFTER seed-import-corridor.sql (KALITY yard, the KALITY→DJIB_PORT export
-- route via ensureExportRoute()).
--
-- A dedicated small-capacity built train at KALITY so the headroom math is
-- exact and cheap: LOCO-ADJ-A/B pull 120T each, 20T overage tolerance ⇒ the
-- consist's pull cap is 120 + 20 = 140T. TRN-ADJ-1 starts with 4 coupled CW4
-- wagons (4 × 24.8T tare = 99.2T, well under cap) plus 2 free spare CW4
-- wagons at the same yard (WGN-ADJ-S1/S2) to add:
-- + one spare -> 124.0T (within the 140T cap) -> adjust-consist SUCCEEDS
-- + both spares -> 148.8T (over the 140T cap) -> adjust-consist REJECTS
--
-- Dedicated codes (LOCO-ADJ-*, TRN-ADJ-1, WGN-ADJ-*) so this spec never
-- competes with the shared KALITY CW4 export pocket other bulk specs draw
-- from (see cargo-two-wagon-types-breaks-length-budget.md for what happens
-- when a spec silently shares fleet stock with another).
-- 1. Locomotives at KALITY: small pull cap, generous length (never binds).
INSERT INTO freight.locomotives
(id, code, max_pull_weight_tons, max_train_length_meters,
overage_tolerance_tons, current_yard_id)
SELECT gen_random_uuid(), v.code, 120, 760, 20, y.id
FROM (VALUES ('LOCO-ADJ-A'), ('LOCO-ADJ-B')) AS v(code)
JOIN freight.yards y ON y.code = 'KALITY'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
-- Keep limits stable across re-seeds (in case a prior run's row predates this
-- fixture's numbers).
UPDATE freight.locomotives
SET max_pull_weight_tons = 120, max_train_length_meters = 760, overage_tolerance_tons = 20
WHERE code IN ('LOCO-ADJ-A', 'LOCO-ADJ-B')
AND (max_pull_weight_tons IS DISTINCT FROM 120
OR max_train_length_meters IS DISTINCT FROM 760
OR overage_tolerance_tons IS DISTINCT FROM 20);
-- 2. The built train, parked at KALITY (must match the export route's origin
-- yard for a built-train schedule to be creatable on it).
INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
SELECT gen_random_uuid(), 'TRN-ADJ-1', 'E2E Adjust-Consist Carrier', 500, y.id
FROM freight.yards y WHERE y.code = 'KALITY'
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-ADJ-1');
-- 3. Couple the locomotive pair.
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
SELECT gen_random_uuid(), t.id, l.id, v.seq
FROM (VALUES ('LOCO-ADJ-A', 0), ('LOCO-ADJ-B', 1)) AS v(loco_code, seq)
JOIN freight.trains t ON t.code = 'TRN-ADJ-1'
JOIN freight.locomotives l ON l.code = v.loco_code
WHERE NOT EXISTS (
SELECT 1 FROM freight.train_locomotives tl
WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
);
-- 4. Dedicated CW4 wagons at KALITY: 4 coupled onto the train, 2 free spares.
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
SELECT gen_random_uuid(), v.num, wt.id, y.id
FROM (VALUES
('WGN-ADJ-C1', 1), ('WGN-ADJ-C2', 2), ('WGN-ADJ-C3', 3), ('WGN-ADJ-C4', 4),
('WGN-ADJ-S1', NULL), ('WGN-ADJ-S2', NULL)
) AS v(num, seq)
JOIN freight.wagon_types wt ON wt.code = 'CW4'
JOIN freight.yards y ON y.code = 'KALITY'
WHERE NOT EXISTS (SELECT 1 FROM freight.wagons w WHERE w.wagon_number = v.num);
-- Couple the C1..C4 quartet onto the train (idempotent re-couple in case a
-- prior run's adjust-consist test detached one).
UPDATE freight.wagons w
SET train_id = t.id, sequence_number = v.seq, status = 'ASSIGNED', current_yard_id = t.current_yard_id
FROM freight.trains t,
(VALUES ('WGN-ADJ-C1', 1), ('WGN-ADJ-C2', 2), ('WGN-ADJ-C3', 3), ('WGN-ADJ-C4', 4))
AS v(num, seq)
WHERE w.wagon_number = v.num AND t.code = 'TRN-ADJ-1'
AND (w.train_id IS DISTINCT FROM t.id OR w.sequence_number IS DISTINCT FROM v.seq);
-- The 2 spares stay loose (train_id NULL), AVAILABLE, at the train's yard —
-- unconditionally re-assert every seed (runs AFTER seed-import-corridor.sql,
-- whose own "re-park the export CW4 pocket" step sweeps any loose CW4 whose
-- number sorts last into NAGAD — 'WGN-ADJ-S%' sorts after every corridor
-- fixture code, so a prior run's spares are exactly the kind it would steal).
UPDATE freight.wagons w
SET train_id = NULL, sequence_number = NULL, status = 'AVAILABLE',
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY')
WHERE w.wagon_number IN ('WGN-ADJ-S1', 'WGN-ADJ-S2');

View File

@@ -0,0 +1,71 @@
-- Arrange-data for flows/government_preemption.cy.ts. Idempotent.
--
-- The real government-company seeder (GovCompaniesSeeder /
-- gov-companies.data.ts) is NOT wired into app.module.ts (commented out —
-- an in-progress feature), so the e2e boot never creates it. This fixture
-- inserts the SAME fixed row (id/tin) as GOV_COMPANIES[0] ("Federal
-- Government of Ethiopia") — a no-op duplicate-safe insert if that seeder is
-- ever turned back on, and the only way e2e can book a government booking
-- today (POST /bookings requires companyId to resolve to a
-- kind='government', status='active' company + an active company profile).
INSERT INTO freight.companies
(id, name, type, kind, status, tin, country, email, phone)
SELECT '0a1b0001-0000-4000-8000-000000000001'::uuid, 'Federal Government of Ethiopia',
'customer', 'government', 'active', '0000000001', 'Ethiopia',
'procurement@gov.et', '+251111000001'
WHERE NOT EXISTS (
SELECT 1 FROM freight.companies WHERE id = '0a1b0001-0000-4000-8000-000000000001'::uuid
);
INSERT INTO freight.company_profiles
(id, company_id, type, reference, status)
SELECT '0b1c0001-0000-4000-8000-000000000001'::uuid,
'0a1b0001-0000-4000-8000-000000000001'::uuid, 'importer', 'IM-90001', 'active'
WHERE NOT EXISTS (
SELECT 1 FROM freight.company_profiles WHERE id = '0b1c0001-0000-4000-8000-000000000001'::uuid
);
-- Dedicated 3-wagon BUILT container train at DJIB_PORT (the default import
-- corridor's origin). A loco-pair schedule's max_wagons is NOT a real cap —
-- syncScheduleMaxWagons recomputes it from the locomotive's length every fill
-- pass (54 for a standard-length loco), ignoring maxWagonsPerTrain entirely.
-- A BUILT train's coupled wagon count IS the cap, immune to that recompute
-- (see seed-adjust-consist.sql for the same pattern) — the only way to get a
-- genuinely small, exact-fit train for the preemption scenario below.
INSERT INTO freight.locomotives
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
SELECT gen_random_uuid(), v.code, 9000, 760, y.id
FROM (VALUES ('LOCO-GOV-A'), ('LOCO-GOV-B')) AS v(code)
JOIN freight.yards y ON y.code = 'DJIB_PORT'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
SELECT gen_random_uuid(), 'TRN-GOV-1', 'E2E Government Preemption Carrier', 300, y.id
FROM freight.yards y WHERE y.code = 'DJIB_PORT'
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-GOV-1');
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
SELECT gen_random_uuid(), t.id, l.id, v.seq
FROM (VALUES ('LOCO-GOV-A', 0), ('LOCO-GOV-B', 1)) AS v(loco_code, seq)
JOIN freight.trains t ON t.code = 'TRN-GOV-1'
JOIN freight.locomotives l ON l.code = v.loco_code
WHERE NOT EXISTS (
SELECT 1 FROM freight.train_locomotives tl
WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
);
INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
SELECT gen_random_uuid(), v.num, wt.id, y.id
FROM (VALUES ('WGN-GOV-C1', 1), ('WGN-GOV-C2', 2), ('WGN-GOV-C3', 3)) AS v(num, seq)
JOIN freight.wagon_types wt ON wt.code = 'NW5'
JOIN freight.yards y ON y.code = 'DJIB_PORT'
WHERE NOT EXISTS (SELECT 1 FROM freight.wagons w WHERE w.wagon_number = v.num);
UPDATE freight.wagons w
SET train_id = t.id, sequence_number = v.seq, status = 'ASSIGNED',
current_yard_id = t.current_yard_id
FROM freight.trains t,
(VALUES ('WGN-GOV-C1', 1), ('WGN-GOV-C2', 2), ('WGN-GOV-C3', 3)) AS v(num, seq)
WHERE w.wagon_number = v.num AND t.code = 'TRN-GOV-1'
AND (w.train_id IS DISTINCT FROM t.id OR w.sequence_number IS DISTINCT FROM v.seq);