mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -33,6 +33,13 @@ async function bootstrap() {
|
|||||||
"delegator-position-id",
|
"delegator-position-id",
|
||||||
"current-project-id",
|
"current-project-id",
|
||||||
"current-position-id",
|
"current-position-id",
|
||||||
|
// x-prefixed variants sent by the user-management / record-management
|
||||||
|
// frontend modules (same values, different naming convention)
|
||||||
|
"x-organization-unit-id",
|
||||||
|
"x-delegator-id",
|
||||||
|
"x-delegator-position-id",
|
||||||
|
"x-current-project-id",
|
||||||
|
"x-current-position-id",
|
||||||
],
|
],
|
||||||
exposedHeaders: ["Content-Disposition"],
|
exposedHeaders: ["Content-Disposition"],
|
||||||
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
maxAge: 86400, // cache preflight for 24h to cut chatter in dev
|
||||||
|
|||||||
525
e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
Normal file
525
e2e/freight/cypress/e2e/flows/export_one_time.cy.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* Export ONE_TIME journeys — two contracts ride the same export train
|
||||||
|
* (Mojo Dry Port → Dire Dawa Yard → Djibouti Port Terminal):
|
||||||
|
*
|
||||||
|
* A. CONTAINER (20ft + 40ft):
|
||||||
|
* portal wizard → staff approval chain → OTP sign → counter-sign
|
||||||
|
* → AWAITING_CLEARANCE_DOCUMENTS (export self-clear has no required
|
||||||
|
* docs in e2e) → ops finalize → FULLY_EXECUTED → customer books
|
||||||
|
* 2 × 20ft + 1 × 40ft picking a real Shipment day → ops accepts the
|
||||||
|
* operation request → EXPORT is FCFS, so accept reserves the train slot
|
||||||
|
* immediately: SELECTED_FOR_BATCH with a pay deadline clamped to the
|
||||||
|
* export window close → staff mark-paid → PAID + SCHEDULED + linked.
|
||||||
|
*
|
||||||
|
* B. BULK (E2E Wheat, 60 tons): same journey through the bulk wizard and
|
||||||
|
* bulk booking form, riding CW4 covered wagons on the same train.
|
||||||
|
*
|
||||||
|
* Infrastructure (route, built train, distances, rates, cargo types) comes
|
||||||
|
* from seed-intercity.sql + seed-export.sql; the export route and the
|
||||||
|
* departing-today schedule are created through the UI when missing.
|
||||||
|
*
|
||||||
|
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const customer = "user@gmail.com";
|
||||||
|
const companyTin = "0102030405"; // seed-company.sql
|
||||||
|
const opsStaff = "operation@edr.local";
|
||||||
|
|
||||||
|
const ORIGIN_YARD = "Mojo Dry Port";
|
||||||
|
const MID_YARD = "Dire Dawa Yard";
|
||||||
|
const PORT_YARD = "Djibouti Port Terminal";
|
||||||
|
const TRAIN_CODE = "TRN-E2E-1";
|
||||||
|
|
||||||
|
// Container numbers must be ISO (4 letters + 7 digits) and unused — stamp per run.
|
||||||
|
const stamp = String(Date.now());
|
||||||
|
const isoNumber = (prefix: string, offset: number) =>
|
||||||
|
`${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`;
|
||||||
|
|
||||||
|
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||||||
|
|
||||||
|
function dbContract(freight: "CONTAINER" | "BULK") {
|
||||||
|
return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>(
|
||||||
|
"db:query",
|
||||||
|
{
|
||||||
|
sql: `SELECT ct.id, ct.reference, ct.status
|
||||||
|
FROM freight.contracts ct
|
||||||
|
JOIN freight.companies c ON c.id = ct.company_id
|
||||||
|
WHERE c.tin = $1 AND ct.trade_direction = 'EXPORT' AND ct.freight_type = $2
|
||||||
|
ORDER BY ct.created_at DESC LIMIT 1`,
|
||||||
|
params: [companyTin, freight],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withContract(
|
||||||
|
freight: "CONTAINER" | "BULK",
|
||||||
|
fn: (c: { id: string; reference: string; status: string }) => void,
|
||||||
|
) {
|
||||||
|
dbContract(freight).then(({ rows }) => {
|
||||||
|
expect(rows, `latest EXPORT ${freight} contract`).to.have.length(1);
|
||||||
|
fn(rows[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectContractStatus(freight: "CONTAINER" | "BULK", expected: string) {
|
||||||
|
dbContract(freight).then(({ rows }) => {
|
||||||
|
expect(rows[0]?.status, "contract status").to.eq(expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dbBooking(freight: "CONTAINER" | "BULK") {
|
||||||
|
return cy.task<{
|
||||||
|
rows: Array<{
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
scheduling_status: string;
|
||||||
|
train_schedule_id: string | null;
|
||||||
|
payment_deadline: string | null;
|
||||||
|
scheduled_date: string | null;
|
||||||
|
}>;
|
||||||
|
}>("db:query", {
|
||||||
|
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
|
||||||
|
b.train_schedule_id, b.payment_deadline, b.scheduled_date
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.companies c ON c.id = b.company_id
|
||||||
|
WHERE c.tin = $1 AND b.trade_direction = 'EXPORT' AND b.freight_type = $2
|
||||||
|
ORDER BY b.created_at DESC LIMIT 1`,
|
||||||
|
params: [companyTin, freight],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withBooking(
|
||||||
|
freight: "CONTAINER" | "BULK",
|
||||||
|
fn: (b: {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
scheduling_status: string;
|
||||||
|
train_schedule_id: string | null;
|
||||||
|
payment_deadline: string | null;
|
||||||
|
scheduled_date: string | null;
|
||||||
|
}) => void,
|
||||||
|
) {
|
||||||
|
dbBooking(freight).then(({ rows }) => {
|
||||||
|
expect(rows, `EXPORT ${freight} booking`).to.have.length(1);
|
||||||
|
fn(rows[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The journey's export schedule. Export trains must be scheduled ≥ the booking
|
||||||
|
* lead (24h) ahead, and their window OPENS at departure − lead — so the spec
|
||||||
|
* departs at now + 24h + a couple of minutes: creatable now, window opens
|
||||||
|
* minutes later. (The intercity spec's train leaves in 2 days — outside 25h.)
|
||||||
|
*/
|
||||||
|
function dbUpcomingSchedule() {
|
||||||
|
return cy.task<{
|
||||||
|
rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>;
|
||||||
|
}>("db:query", {
|
||||||
|
sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
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'
|
||||||
|
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The train departs ~24h out — bookings ride its departure day (tomorrow). */
|
||||||
|
const SHIPMENT_DAY = new Date(Date.now() + 24 * 3_600_000 + 150_000);
|
||||||
|
|
||||||
|
function fill(label: string | RegExp, value: string) {
|
||||||
|
cy.contains("label", label)
|
||||||
|
.invoke("attr", "for")
|
||||||
|
.then((id) => {
|
||||||
|
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the Shipment day picker and choose the train's departure day. */
|
||||||
|
function pickShipmentDay() {
|
||||||
|
cy.contains("label", /^Shipment day/)
|
||||||
|
.invoke("attr", "for")
|
||||||
|
.then((id) => {
|
||||||
|
cy.get(`[id="${id}"]`).click({ force: true });
|
||||||
|
});
|
||||||
|
const day = String(SHIPMENT_DAY.getDate());
|
||||||
|
cy.get(".mantine-Popover-dropdown button:not([data-disabled]):not([disabled])", {
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
.contains(new RegExp(`^${day}$`))
|
||||||
|
.click({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared staff steps: accept + LINE_STAFF approve, then director approve. */
|
||||||
|
function approveChain(freight: "CONTAINER" | "BULK") {
|
||||||
|
cy.loginBackoffice("marketer@edr.local");
|
||||||
|
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||||
|
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||||||
|
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||||||
|
.should("not.be.disabled")
|
||||||
|
.click();
|
||||||
|
cy.contains("Approval chain", { timeout: 20000 }).should("be.visible");
|
||||||
|
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||||||
|
cy.contains("button", "Confirm approval").click();
|
||||||
|
cy.contains("1/2", { timeout: 20000 }).should("be.visible");
|
||||||
|
|
||||||
|
cy.loginBackoffice("director@edr.local");
|
||||||
|
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||||
|
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||||||
|
cy.contains("button", "Confirm approval").click();
|
||||||
|
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
|
||||||
|
expectContractStatus(freight, "CONTRACT_READY");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared customer OTP-signature step. */
|
||||||
|
function customerSigns(freight: "CONTAINER" | "BULK") {
|
||||||
|
cy.loginPortal(customer);
|
||||||
|
withContract(freight, (c) => cy.visitPortal(`/contracts/${c.id}/view`));
|
||||||
|
|
||||||
|
const unlockConsent = (attempt: number) => {
|
||||||
|
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
|
||||||
|
const win = ($f[0] as HTMLIFrameElement).contentWindow;
|
||||||
|
const el = win?.document?.scrollingElement ?? win?.document?.documentElement;
|
||||||
|
if (win && el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
win.dispatchEvent(new Event("scroll"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cy.wait(500).then(() => {
|
||||||
|
cy.get("body").then(($b) => {
|
||||||
|
if ($b.text().includes("I have read the entire contract")) return;
|
||||||
|
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||||||
|
unlockConsent(attempt + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
unlockConsent(0);
|
||||||
|
|
||||||
|
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
||||||
|
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||||||
|
cy.contains("label", "Full name")
|
||||||
|
.invoke("attr", "for")
|
||||||
|
.then((id) => {
|
||||||
|
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||||||
|
});
|
||||||
|
cy.drawSignature();
|
||||||
|
cy.contains("button", "Continue to verification").click();
|
||||||
|
cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
|
||||||
|
cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
|
||||||
|
cy.contains("button", "Verify & sign").click();
|
||||||
|
cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible");
|
||||||
|
expectContractStatus(freight, "SIGNED_CUSTOMER");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared counter-sign + ops finalize (export self-clear: no required docs in e2e). */
|
||||||
|
function counterSignAndFinalize(freight: "CONTAINER" | "BULK") {
|
||||||
|
cy.loginBackoffice("marketer@edr.local");
|
||||||
|
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
|
||||||
|
cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
|
||||||
|
cy.contains("label", "Full name")
|
||||||
|
.invoke("attr", "for")
|
||||||
|
.then((id) => {
|
||||||
|
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||||||
|
});
|
||||||
|
cy.drawSignature();
|
||||||
|
cy.get(".mantine-Modal-content")
|
||||||
|
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||||||
|
.click();
|
||||||
|
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
|
||||||
|
expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS");
|
||||||
|
|
||||||
|
cy.loginBackoffice(opsStaff);
|
||||||
|
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||||
|
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
||||||
|
cy.contains("button", "Finalize document approval", { timeout: 30000 })
|
||||||
|
.should("not.be.disabled")
|
||||||
|
.click();
|
||||||
|
cy.contains("finalized", { timeout: 30000 }).should("be.visible");
|
||||||
|
expectContractStatus(freight, "FULLY_EXECUTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops accept: EXPORT is FCFS — accept reserves the slot and opens the pay window. */
|
||||||
|
function acceptAndAssertReserved(freight: "CONTAINER" | "BULK") {
|
||||||
|
cy.loginBackoffice(opsStaff);
|
||||||
|
withBooking(freight, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
|
||||||
|
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
|
||||||
|
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
|
||||||
|
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
|
||||||
|
cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist");
|
||||||
|
|
||||||
|
dbUpcomingSchedule().then(({ rows: schedules }) => {
|
||||||
|
expect(schedules, "departing-today export schedule").to.have.length(1);
|
||||||
|
withBooking(freight, (b) => {
|
||||||
|
expect(b.status, "FCFS reservation").to.eq("SELECTED_FOR_BATCH");
|
||||||
|
expect(b.train_schedule_id).to.eq(schedules[0].id);
|
||||||
|
// Export parity: the pay window never outlives the booking window close.
|
||||||
|
expect(b.payment_deadline, "pay deadline set").to.be.a("string");
|
||||||
|
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
|
||||||
|
new Date(schedules[0].window_closes_at).getTime(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPaidAndAssertAllocated(freight: "CONTAINER" | "BULK") {
|
||||||
|
withBooking(freight, (b) => {
|
||||||
|
cy.apiLogin(opsStaff).then(({ token }) => {
|
||||||
|
cy.request({
|
||||||
|
method: "POST",
|
||||||
|
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.its("status")
|
||||||
|
.should("be.oneOf", [200, 201]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
withBooking(freight, (b) => {
|
||||||
|
expect(b.status).to.eq("PAID");
|
||||||
|
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||||||
|
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||||
|
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||||
|
params: [b.id],
|
||||||
|
}).then(({ rows }) => {
|
||||||
|
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Shared infrastructure ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it("operations ensures the export route exists", () => {
|
||||||
|
cy.loginBackoffice(opsStaff);
|
||||||
|
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||||||
|
sql: `SELECT count(*) AS n
|
||||||
|
FROM freight.routes r
|
||||||
|
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||||||
|
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
|
||||||
|
WHERE r.deleted_at IS NULL`,
|
||||||
|
}).then(({ rows }) => {
|
||||||
|
if (Number(rows[0].n) > 0) return;
|
||||||
|
|
||||||
|
cy.visit("/dashboard/routes");
|
||||||
|
cy.contains("button", "Add route", { timeout: 20000 }).click();
|
||||||
|
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
|
||||||
|
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
|
||||||
|
const pickYard = (index: number, yard: string) => {
|
||||||
|
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
|
||||||
|
.eq(index)
|
||||||
|
.click({ force: true });
|
||||||
|
cy.get('[role="option"]:visible').contains(yard).click();
|
||||||
|
};
|
||||||
|
pickYard(0, ORIGIN_YARD);
|
||||||
|
pickYard(1, MID_YARD);
|
||||||
|
pickYard(2, PORT_YARD);
|
||||||
|
cy.get(".mantine-Modal-content").contains("button", "Save").click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("operations schedules the export train — booking window opens", () => {
|
||||||
|
cy.loginBackoffice(opsStaff);
|
||||||
|
|
||||||
|
dbUpcomingSchedule().then(({ rows }) => {
|
||||||
|
if (rows.length > 0) return;
|
||||||
|
|
||||||
|
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||||||
|
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||||||
|
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||||||
|
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
|
||||||
|
|
||||||
|
// Just past the 24h scheduling lead: creatable now, and the export
|
||||||
|
// window (opens departure − lead) flips OPEN a couple of minutes later.
|
||||||
|
const local = new Date(
|
||||||
|
SHIPMENT_DAY.getTime() - SHIPMENT_DAY.getTimezoneOffset() * 60000,
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16);
|
||||||
|
cy.get('.mantine-Modal-content input[type="datetime-local"]')
|
||||||
|
.clear({ force: true })
|
||||||
|
.type(local, { force: true });
|
||||||
|
cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
|
||||||
|
cy.get(".mantine-Modal-content").contains("button", "Create").click();
|
||||||
|
cy.location("pathname", { timeout: 30000 }).should(
|
||||||
|
"match",
|
||||||
|
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes.
|
||||||
|
const waitForOpenWindow = (attempt: number) => {
|
||||||
|
dbUpcomingSchedule().then(({ rows }) => {
|
||||||
|
expect(rows, "upcoming export schedule").to.have.length(1);
|
||||||
|
if (rows[0].booking_window_status === "OPEN") return;
|
||||||
|
expect(attempt, "export booking window OPEN").to.be.lessThan(40);
|
||||||
|
cy.wait(10000).then(() => waitForOpenWindow(attempt + 1));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
waitForOpenWindow(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Journey A: container 20ft + 40ft ──────────────────────────────────────
|
||||||
|
|
||||||
|
it("customer submits an export container contract (20ft + 40ft)", () => {
|
||||||
|
cy.loginPortal(customer);
|
||||||
|
cy.visitPortal("/contracts/new");
|
||||||
|
|
||||||
|
cy.mantineSelect(/^Operation Type/, /^Export$/);
|
||||||
|
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||||
|
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||||
|
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
||||||
|
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||||
|
cy.contains("button", "Continue").click({ force: true });
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
cy.contains("button", "Submit").click({ force: true });
|
||||||
|
cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
|
||||||
|
cy.contains("button", "Approve & submit").click();
|
||||||
|
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
|
||||||
|
|
||||||
|
expectContractStatus("CONTAINER", "SUBMITTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staff approve the container contract (marketer + director)", () => {
|
||||||
|
approveChain("CONTAINER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("customer signs the container contract with OTP", () => {
|
||||||
|
customerSigns("CONTAINER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staff counter-sign and operations finalize the container contract", () => {
|
||||||
|
counterSignAndFinalize("CONTAINER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("customer books 2 × 20ft + 1 × 40ft with a shipment day", () => {
|
||||||
|
cy.loginPortal(customer);
|
||||||
|
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");
|
||||||
|
|
||||||
|
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3);
|
||||||
|
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0));
|
||||||
|
cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", 1));
|
||||||
|
cy.get('input[placeholder*="MSCU"]').eq(2).type(isoNumber("FSCU", 2));
|
||||||
|
|
||||||
|
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||||||
|
cy.wrap($input).clear({ force: true }).type("10", { force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
pickShipmentDay();
|
||||||
|
|
||||||
|
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||||
|
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||||
|
cy.contains("button", "Confirm & book").click();
|
||||||
|
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||||
|
|
||||||
|
withBooking("CONTAINER", (b) => {
|
||||||
|
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||||
|
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("operations accepts the container request — FCFS reserves today's train", () => {
|
||||||
|
acceptAndAssertReserved("CONTAINER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staff mark the container booking paid — allocated onto the train", () => {
|
||||||
|
markPaidAndAssertAllocated("CONTAINER");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Journey B: bulk (E2E Wheat) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
it("customer submits an export bulk contract (wheat)", () => {
|
||||||
|
cy.loginPortal(customer);
|
||||||
|
cy.visitPortal("/contracts/new");
|
||||||
|
|
||||||
|
cy.mantineSelect(/^Operation Type/, /^Export$/);
|
||||||
|
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||||||
|
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||||||
|
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
|
||||||
|
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||||||
|
cy.contains("button", "Continue").click({ force: true });
|
||||||
|
|
||||||
|
cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
|
||||||
|
cy.mantineSelect(/^Bulk Cargo Type/, "E2E Grains");
|
||||||
|
cy.mantineSelect(/^Commodity/, "E2E Wheat");
|
||||||
|
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
|
||||||
|
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
|
||||||
|
cy.contains("button", "Continue").click({ force: true });
|
||||||
|
|
||||||
|
cy.contains("button", "Submit").click({ force: true });
|
||||||
|
cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
|
||||||
|
cy.contains("button", "Approve & submit").click();
|
||||||
|
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
|
||||||
|
|
||||||
|
expectContractStatus("BULK", "SUBMITTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staff approve the bulk contract (marketer + director)", () => {
|
||||||
|
approveChain("BULK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("customer signs the bulk contract with OTP", () => {
|
||||||
|
customerSigns("BULK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staff counter-sign and operations finalize the bulk contract", () => {
|
||||||
|
counterSignAndFinalize("BULK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("customer books 60 tons of wheat with a shipment day", () => {
|
||||||
|
cy.loginPortal(customer);
|
||||||
|
withContract("BULK", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
|
||||||
|
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||||||
|
|
||||||
|
fill(/^Quantity \(tons\)/, "60");
|
||||||
|
pickShipmentDay();
|
||||||
|
|
||||||
|
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||||||
|
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||||||
|
cy.contains("button", "Confirm & book").click();
|
||||||
|
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||||||
|
|
||||||
|
withBooking("BULK", (b) => {
|
||||||
|
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("operations accepts the bulk request — FCFS reserves today's train", () => {
|
||||||
|
acceptAndAssertReserved("BULK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staff mark the bulk booking paid — allocated onto the train", () => {
|
||||||
|
markPaidAndAssertAllocated("BULK");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -10,9 +10,13 @@
|
|||||||
* → CONTRACT_READY
|
* → CONTRACT_READY
|
||||||
* 5. portal — customer OTP-signs → SIGNED_CUSTOMER
|
* 5. portal — customer OTP-signs → SIGNED_CUSTOMER
|
||||||
* 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS
|
* 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS
|
||||||
* (ONE_TIME intercity always passes the intercity-documents
|
* (ONE_TIME intercity always routes through the
|
||||||
* step; the e2e setting has no required fields)
|
* intercity-documents step; the fixture seeds one REQUIRED
|
||||||
* 7. backoffice — operations finalizes document approval → FULLY_EXECUTED
|
* document so the step is real)
|
||||||
|
* 6b. portal — customer uploads the required intercity document
|
||||||
|
* → CLEARANCE_UNDER_REVIEW
|
||||||
|
* 7. backoffice — operations approves the document, then finalizes document
|
||||||
|
* approval → FULLY_EXECUTED
|
||||||
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
||||||
* no shipment date) → booking OPERATION_REQUEST_PENDING
|
* no shipment date) → booking OPERATION_REQUEST_PENDING
|
||||||
* 9. backoffice — operations accepts the operation request → booking
|
* 9. backoffice — operations accepts the operation request → booking
|
||||||
@@ -314,15 +318,36 @@ describe("intercity one-time journey: contract → booking → export train", {
|
|||||||
expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS");
|
expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("operations finalizes document approval — contract fully executed", () => {
|
it("customer uploads the required intercity document", () => {
|
||||||
|
cy.loginPortal(customer);
|
||||||
|
// Deep link auto-opens the clearance documents modal.
|
||||||
|
withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`));
|
||||||
|
|
||||||
|
cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist");
|
||||||
|
cy.get('.mantine-Modal-content input[type="file"]')
|
||||||
|
.first()
|
||||||
|
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
|
||||||
|
cy.contains("button", "Submit documents", { timeout: 15000 })
|
||||||
|
.should("not.be.disabled")
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Upload hands the contract to Operations review — the card flips to
|
||||||
|
// "Under review" (the host modal may linger while queries refetch).
|
||||||
|
cy.contains("Under review", { timeout: 30000 }).should("exist");
|
||||||
|
expectContractStatus("CLEARANCE_UNDER_REVIEW");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("operations approves the document and finalizes — contract fully executed", () => {
|
||||||
cy.loginBackoffice(opsStaff);
|
cy.loginBackoffice(opsStaff);
|
||||||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||||||
|
|
||||||
// The clearance review section lives behind its own tab on the detail page.
|
// The clearance review section lives behind its own tab on the detail page.
|
||||||
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
||||||
|
|
||||||
// The e2e intercity_documents setting has no required fields, so the
|
// Approve the uploaded Cargo Manifest, then finalize.
|
||||||
// review section is immediately finalizable.
|
cy.contains("button", /Approve all/, { timeout: 30000 }).click();
|
||||||
|
cy.contains("1/1 approved", { timeout: 30000 }).should("exist");
|
||||||
|
|
||||||
cy.contains("button", "Finalize document approval", { timeout: 30000 })
|
cy.contains("button", "Finalize document approval", { timeout: 30000 })
|
||||||
.should("not.be.disabled")
|
.should("not.be.disabled")
|
||||||
.click();
|
.click();
|
||||||
|
|||||||
73
e2e/freight/cypress/fixtures/seed-export.sql
Normal file
73
e2e/freight/cypress/fixtures/seed-export.sql
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
-- Arrange-data for flows/export_one_time.cy.ts. Idempotent.
|
||||||
|
-- Run AFTER seed-intercity.sql (reuses its container types, locomotives,
|
||||||
|
-- built train TRN-E2E-1 and yard distances).
|
||||||
|
--
|
||||||
|
-- 1. bulk cargo hierarchy: group "E2E Grains" → commodity "E2E Wheat",
|
||||||
|
-- carried on CW4 covered wagons
|
||||||
|
-- 2. two CW4 wagons coupled onto the train (bulk capacity)
|
||||||
|
-- 3. LIVE export rates for Mojo → Djibouti Port: container (per container)
|
||||||
|
-- and bulk (per ton) — booking pricing hard-blocks without them
|
||||||
|
|
||||||
|
-- 1a. Cargo type group + commodity.
|
||||||
|
INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active)
|
||||||
|
SELECT gen_random_uuid(), 'E2E_GRAINS', 'E2E Grains', true
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_GRAINS');
|
||||||
|
|
||||||
|
INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active)
|
||||||
|
SELECT gen_random_uuid(), 'E2E_WHEAT', 'E2E Wheat', g.id, true
|
||||||
|
FROM freight.cargo_types g
|
||||||
|
WHERE g.code = 'E2E_GRAINS'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_WHEAT');
|
||||||
|
|
||||||
|
-- 1b. Wheat rides CW4 covered wagons.
|
||||||
|
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
|
||||||
|
SELECT ct.id, wt.id
|
||||||
|
FROM freight.cargo_types ct
|
||||||
|
JOIN freight.wagon_types wt ON wt.code = 'CW4'
|
||||||
|
WHERE ct.code IN ('E2E_WHEAT', 'E2E_GRAINS')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.cargo_type_wagon_types x
|
||||||
|
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Couple two free CW4 wagons onto the train, parked at Mojo with it.
|
||||||
|
UPDATE freight.wagons w
|
||||||
|
SET train_id = t.id,
|
||||||
|
sequence_number = 100 + sub.rn,
|
||||||
|
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
|
||||||
|
FROM freight.trains t,
|
||||||
|
LATERAL (
|
||||||
|
SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
|
||||||
|
FROM freight.wagons w2
|
||||||
|
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
|
||||||
|
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
|
||||||
|
ORDER BY w2.wagon_number
|
||||||
|
LIMIT 2
|
||||||
|
) sub
|
||||||
|
WHERE t.code = 'TRN-E2E-1'
|
||||||
|
AND w.id = sub.id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.wagons wx
|
||||||
|
JOIN freight.wagon_types wxt ON wxt.id = wx.wagon_type_id AND wxt.code = 'CW4'
|
||||||
|
WHERE wx.train_id = t.id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. LIVE export rates Mojo → Djibouti Port.
|
||||||
|
INSERT INTO freight.rates
|
||||||
|
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
|
||||||
|
origin_yard_id, destination_yard_id, proposed_by_staff_id)
|
||||||
|
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
|
||||||
|
v.unit, 'LIVE', a.id, b.id, u.id
|
||||||
|
FROM (VALUES
|
||||||
|
('CONTAINER_EXPORT', 'CONTAINER', 600, 'PER_CONTAINER'),
|
||||||
|
('BULK_EXPORT', 'BULK', 25, 'PER_TON')
|
||||||
|
) AS v(rate_type, applies_to, value, unit)
|
||||||
|
JOIN freight.yards a ON a.code = 'MOJO'
|
||||||
|
JOIN freight.yards b ON b.code = 'DJIB_PORT'
|
||||||
|
JOIN iam.users u ON u.email = 'operation@edr.local'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.rates r
|
||||||
|
WHERE r.rate_type = v.rate_type
|
||||||
|
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
);
|
||||||
@@ -76,6 +76,21 @@ WHERE t.code = 'TRN-E2E-1'
|
|||||||
AND w.id = sub.id
|
AND w.id = sub.id
|
||||||
AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id);
|
AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id);
|
||||||
|
|
||||||
|
-- 4d. One REQUIRED intercity clearance document, so the journey exercises the
|
||||||
|
-- real customer-upload → ops-review → finalize step (the seeder leaves the
|
||||||
|
-- intercity_documents setting empty).
|
||||||
|
INSERT INTO freight.file_upload_fields
|
||||||
|
(id, setting_id, file_key, file_label, is_required, is_multiple, max_files,
|
||||||
|
allowed_extensions, max_size_mb, display_order)
|
||||||
|
SELECT gen_random_uuid(), s.id, 'cargo_manifest', 'Cargo Manifest', true, false, 1,
|
||||||
|
'{pdf,jpg,jpeg,png}'::text[], 10, 1
|
||||||
|
FROM freight.file_upload_settings s
|
||||||
|
WHERE s.code = 'intercity_documents'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.file_upload_fields f
|
||||||
|
WHERE f.setting_id = s.id AND f.file_key = 'cargo_manifest' AND f.deleted_at IS NULL
|
||||||
|
);
|
||||||
|
|
||||||
-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing
|
-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing
|
||||||
-- hard-blocks any container line without a rate on its exact leg; rates are
|
-- hard-blocks any container line without a rate on its exact leg; rates are
|
||||||
-- configured in USD and converted to the booking currency).
|
-- configured in USD and converted to the booking currency).
|
||||||
|
|||||||
Reference in New Issue
Block a user