add dispute functionality for contract duty and implement collection dates

This commit is contained in:
Marshal
2026-07-26 16:58:51 +00:00
parent 9b13fa2ac6
commit 5e10c97294
74 changed files with 3684 additions and 342 deletions

View File

@@ -41,7 +41,108 @@ export default defineConfig({
process.env.E2E_DB_URL ??
"postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";
// Shapes already retired in THIS cypress run. The Node plugin process
// outlives spec-bundle re-evaluation (Cypress re-runs the bundle — and
// so `before()` — on every cross-origin visit), so a run-scoped set here
// is what keeps the cleanup from firing a second time and cancelling the
// contract the spec had just created. Browser-side state cannot do this:
// Cypress.env() is reset by the reload.
const retiredContractShapes = new Set<string>();
/** Keys already executed by `db:queryOnce` in this cypress run. */
const onceRunKeys = new Set<string>();
// Stamped when the plugin loads, i.e. once per cypress run. Cleanup only
// ever touches rows older than this, so nothing the current run creates
// can be cancelled out from under it.
const runStartedAt = new Date().toISOString();
on("task", {
/**
* Cancel a previous run's contracts of one shape so a spec can run
* again against a warm DB (the API allows one active contract per
* customer + service type + route). Runs at most once per shape per
* cypress run — see `retiredContractShapes`.
*/
async "db:retireStaleContracts"({
tin,
kind,
direction,
}: {
tin: string;
kind: string;
direction: string;
}) {
const key = `${tin}:${kind}:${direction}`;
if (retiredContractShapes.has(key)) return { skipped: true };
retiredContractShapes.add(key);
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
const result = await client.query(
`UPDATE freight.contracts ct
SET status = 'CANCELLED'
FROM freight.companies c
WHERE c.id = ct.company_id
AND c.tin = $1
AND ct.deleted_at IS NULL
AND ct.contract_kind = $2
AND ct.trade_direction = $3
-- Only ever previous runs' rows.
AND ct.created_at < $4::timestamptz
-- Corridor fixtures are re-seeded by reference, so a
-- cancelled one would never come back — leave them alone.
AND ct.reference NOT LIKE 'CTR-IMP-%'
-- Segment fixtures are stamped per run and always booked by
-- their own spec. An UNBOOKED leftover is debris that still
-- holds the lane (one active contract per service + route),
-- which blocked the intercity spec from filing its own.
AND (
ct.reference NOT LIKE 'CTR-SEG-%'
OR NOT EXISTS (
SELECT 1 FROM freight.bookings b
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
)
)
AND ct.status NOT IN
('REJECTED','CANCELLED','CONTRACT_CLOSED','ARCHIVED','EXPIRED')`,
[tin, kind, direction, runStartedAt],
);
return { skipped: false, cancelled: result.rowCount };
} finally {
await client.end();
}
},
/**
* Run a statement at most ONCE per cypress run, keyed by `key`.
*
* For arrange-data that must not repeat: Cypress re-evaluates the spec
* bundle on every cross-origin visit, so a `before()` hook fires again
* mid-spec and a plain cleanup would then wipe what the run had just
* created. The Node plugin process outlives those reloads, so the guard
* lives here rather than in the browser.
*/
async "db:queryOnce"({
key,
sql,
params = [],
}: {
key: string;
sql: string;
params?: unknown[];
}) {
if (onceRunKeys.has(key)) return { skipped: true };
onceRunKeys.add(key);
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
const result = await client.query(sql, params as never[]);
return { skipped: false, rowCount: result.rowCount };
} finally {
await client.end();
}
},
/** Run an arbitrary SQL statement against the ephemeral e2e database. */
async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
const client = new Client({ connectionString: dbUrl });

View File

@@ -50,6 +50,20 @@ function expectStatus(expected: string) {
}
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
before(() => {
// Re-runnable against a warm DB. The API allows one active contract per
// customer + service type + route, so this spec's own contract from an
// earlier run 409s the new one at creation — and since staff accept it
// with a year's validity, it would keep doing so for a year. Retire just
// the shape this spec creates (its own wizard-made GENERAL imports),
// leaving the seeded corridor/segment fixtures alone.
cy.retireStaleContracts({
tin: companyTin,
kind: "GENERAL",
direction: "IMPORT",
});
});
it("customer creates and submits a GENERAL import container contract", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");

View File

@@ -117,9 +117,16 @@ function dbUpcomingSchedule() {
return cy.task<{
rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>;
}>("db:query", {
// Scoped to THIS journey's corridor (… → Djibouti Port). Direction + a 25h
// window alone also matches the segment-weight spec's trains, which run
// Mojo → Nagad on the same day — and one of those is deliberately driven
// FULL, so the unscoped query handed this spec a full schedule whose
// booking window never opens.
sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status
FROM freight.train_schedules ts
JOIN freight.yards d ON d.id = ts.destination_station_id
WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL
AND d.code = 'DJIB_PORT'
AND ts.scheduled_departure_date > now()
AND ts.scheduled_departure_date < now() + interval '25 hours'
ORDER BY ts.created_at DESC LIMIT 1`,
@@ -137,14 +144,17 @@ function fill(label: string | RegExp, value: string) {
});
}
/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */
function fillNth(label: RegExp, index: number, value: string) {
cy.get("label").then(($labels) => {
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
const id = matches.eq(index).attr("for");
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
});
/**
* Set one container-size line's quantity, found by its "20ft containers" /
* "40ft containers" heading rather than by position in the list.
*/
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
cy.contains(`${size} containers`, { timeout: 15000 })
.closest("div.rounded-xl")
.find('input[type="number"]')
.first()
.clear({ force: true })
.type(value, { force: true });
}
/**
@@ -315,6 +325,51 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
before(() => {
cy.task("db:seedFile", "seed-intercity.sql");
cy.task("db:seedFile", "seed-export.sql");
// Re-runnable against a warm DB: one active contract per customer +
// service type + route, so an earlier run's ONE_TIME export contract
// 409s this run at creation. A SPENT one (already booked) no longer
// blocks, but a run that died before booking leaves a live one behind.
cy.retireStaleContracts({
tin: companyTin,
kind: "ONE_TIME",
direction: "EXPORT",
});
// The schedule step reuses an upcoming export schedule when one exists, but
// a previous run's train is already loaded — and an export booking must
// ride a single train WHOLE, so this run's booking then fails the space
// check with "no open train on this day can carry it". Retire the older
// schedules (unlinking their bookings) so this run gets an empty train.
// Once per run: before() fires again on cross-origin visits, and a second
// pass would delete the schedule this run had just created.
cy.task("db:queryOnce", {
key: "export_one_time:reset-upcoming-export-schedule",
sql: `WITH stale AS (
SELECT ts.id
FROM freight.train_schedules ts
JOIN freight.yards d
ON d.id = ts.destination_station_id AND d.code = 'DJIB_PORT'
WHERE ts.direction = 'EXPORT'
AND ts.deleted_at IS NULL
AND ts.scheduled_departure_date > now()
AND ts.scheduled_departure_date < now() + interval '25 hours'
), unlink AS (
UPDATE freight.bookings b
SET train_schedule_id = NULL,
scheduling_status = 'NOT_SCHEDULED',
status = CASE
WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT')
THEN 'EXPIRED' ELSE b.status END
WHERE 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
)
UPDATE freight.train_schedules
SET deleted_at = now()
WHERE id IN (SELECT id FROM stale)`,
});
});
// ── Shared infrastructure ─────────────────────────────────────────────────
@@ -457,9 +512,13 @@ describe("export one-time journeys: container + bulk on one train", { retries: 0
withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
// Two size editors, each with its own "Quantity *" (20ft first, then 40ft).
fillNth(/^Quantity/, 0, "2");
fillNth(/^Quantity/, 1, "1");
// Address each size editor by its heading, not by position: the two lines
// come out of the contract's cargo scope and are not guaranteed to be in
// 20ft-then-40ft order. Reversed, this booked 1 × 20ft — an odd count,
// which the form blocks (a lone 20ft can never be paired onto a wagon), so
// "Review price & book" stayed disabled.
fillSizeQuantity("20ft", "2");
fillSizeQuantity("40ft", "1");
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3);
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0));

View File

@@ -67,6 +67,23 @@ function requestByReason(reason: string) {
describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
// This spec asserts the "no available wagons" guard against E2E_AWASH, so
// that yard must actually hold none. Run alone it does, but in a full-suite
// run an earlier corridor spec can leave idle wagons standing there and the
// guard then returns 201 instead of 400. Park them back at KALITY — only
// loose AVAILABLE wagons move, so nothing another spec is using is touched.
cy.task("db:query", {
sql: `UPDATE freight.wagons w
SET current_yard_id = k.id
FROM freight.yards a, freight.yards k
WHERE a.code = 'E2E_AWASH'
AND k.code = 'KALITY'
AND w.current_yard_id = a.id
AND w.train_id IS NULL
AND w.current_train_schedule_id IS NULL
AND w.status = 'AVAILABLE'
AND w.deleted_at IS NULL`,
});
});
it("files a count-only request — same-yard and empty-source are rejected", () => {

View File

@@ -136,13 +136,13 @@ function dbSchedule() {
* Fill the N-th labelled Mantine input (label[for] → input id). Indexed
* because the booking form renders one "Quantity *" per container size.
*/
function fillNth(label: RegExp, index: number, value: string) {
cy.get("label").then(($labels) => {
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
const id = matches.eq(index).attr("for");
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
});
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
cy.contains(`${size} containers`, { timeout: 15000 })
.closest("div.rounded-xl")
.find('input[type="number"]')
.first()
.clear({ force: true })
.type(value, { force: true });
}
/**
@@ -188,6 +188,14 @@ describe("intercity one-time journey: contract → booking → export train", {
// Container types, locomotives, built train, yard distances — the
// infrastructure the UI journey cannot create in-flow.
cy.task("db:seedFile", "seed-intercity.sql");
// Re-runnable against a warm DB: an earlier run's DOMESTIC ONE_TIME
// contract on this lane would 409 this run's at creation (a spent one no
// longer blocks, but an unbooked leftover does).
cy.retireStaleContracts({
tin: companyTin,
kind: "ONE_TIME",
direction: "DOMESTIC",
});
});
// ── Contract: submit → reject → resubmit → approve → sign ────────────────
@@ -390,8 +398,8 @@ describe("intercity one-time journey: contract → booking → export train", {
// 40ft line, each seeded quantity 1. Book 2 × 20ft (pairs share a wagon,
// so the count must be even) and zero the 40ft line — otherwise its
// default unit adds a third container-number input.
fillNth(/^Quantity/, 0, "2");
fillNth(/^Quantity/, 1, "0");
fillSizeQuantity("20ft", "2");
fillSizeQuantity("40ft", "0");
// One ISO container number per unit.
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);

View File

@@ -199,6 +199,26 @@ Cypress.Commands.add("fillCargoDescription", (text = "Electronics") => {
});
});
/**
* Retire a previous run's contracts so a spec is re-runnable against a warm DB.
*
* The API allows one active contract per customer + service type + route, so
* yesterday's contract 409s today's at creation — and staff accept contracts
* with a year's validity, so it would keep doing so for a year.
*
* The cutoff is frozen on first use: Cypress re-evaluates the spec bundle on
* cross-origin visits, so `before()` fires again mid-run, and a naive "cancel
* this shape" would then cancel the contract THIS run had just created.
* Anything created after the spec started is ours and must survive.
*/
Cypress.Commands.add(
"retireStaleContracts",
(opts: { tin: string; kind: string; direction: string }) => {
// The once-per-run guard lives in the Node task (see cypress.config.ts).
cy.task("db:retireStaleContracts", opts);
},
);
/** Type a 6-digit code into a Mantine PinInput. */
Cypress.Commands.add("typeOtp", (code: string) => {
cy.get(".mantine-PinInput-root input").should("have.length.at.least", code.length);
@@ -257,6 +277,12 @@ declare global {
uploadCompanyStamp(): Chainable<void>;
/** Fill the required per-booking cargo description (container only). */
fillCargoDescription(text?: string): Chainable<void>;
/** Cancel a previous run's contracts of this shape (warm-DB re-runs). */
retireStaleContracts(opts: {
tin: string;
kind: string;
direction: string;
}): Chainable<void>;
/** Fill a Mantine PinInput with a code. */
typeOtp(code: string): Chainable<void>;
/** Scribble on the signature-pad canvas inside the open modal. */