From f103b51de5b5e5e10df2b631057c4f0824411d54 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 3 Aug 2026 10:51:43 +0000 Subject: [PATCH 01/10] feat: integration tests --- docker-compose.it.yaml | 165 ++ integration/README.md | 156 ++ integration/gateway-mock/server.js | 299 +++ integration/package.json | 21 + integration/scripts/it.mjs | 169 ++ integration/sql/seed-company-b.sql | 34 + integration/sql/seed-customs-service-type.sql | 17 + integration/src/authz.it.ts | 92 + .../src/bulk-b1-priority-expiry-refill.it.ts | 180 ++ integration/src/bulk-b2-per-item-floor.it.ts | 141 ++ integration/src/bulk-b3-per-item-giant.it.ts | 173 ++ integration/src/bulk-export-fcfs-space.it.ts | 182 ++ integration/src/bulk-export-full-train.it.ts | 169 ++ integration/src/bulk-export-matrix.it.ts | 183 ++ integration/src/bulk-export-pay-or-lose.it.ts | 196 ++ integration/src/bulk-import-full-train.it.ts | 185 ++ integration/src/bulk-import-matrix.it.ts | 224 +++ .../src/bulk-import-split-promote.it.ts | 195 ++ .../src/bulk-import-waiting-expiry.it.ts | 125 ++ .../src/bulk-import-window-reopen.it.ts | 127 ++ integration/src/cbe-bill.it.ts | 180 ++ integration/src/client.ts | 218 +++ integration/src/concurrency.it.ts | 215 +++ integration/src/flows.ts | 1680 +++++++++++++++++ .../src/g1-s1-expiry-promotes-waitlist.it.ts | 181 ++ integration/src/g1-s2-exact-fill.it.ts | 142 ++ .../src/g1-s3-underfill-day-open.it.ts | 141 ++ integration/src/g1-s4-split-closes-gap.it.ts | 172 ++ integration/src/g1-s5-cascading-expiry.it.ts | 190 ++ .../g1-s6-s8-offers-government-tiers.it.ts | 420 +++++ integration/src/global-setup.ts | 70 + integration/src/payment-failure.it.ts | 249 +++ integration/src/payment-happy.it.ts | 178 ++ integration/tsconfig.json | 16 + integration/vitest.config.ts | 18 + package.json | 4 + pnpm-lock.yaml | 426 ++--- pnpm-workspace.yaml | 1 + 38 files changed, 7306 insertions(+), 228 deletions(-) create mode 100644 docker-compose.it.yaml create mode 100644 integration/README.md create mode 100644 integration/gateway-mock/server.js create mode 100644 integration/package.json create mode 100644 integration/scripts/it.mjs create mode 100644 integration/sql/seed-company-b.sql create mode 100644 integration/sql/seed-customs-service-type.sql create mode 100644 integration/src/authz.it.ts create mode 100644 integration/src/bulk-b1-priority-expiry-refill.it.ts create mode 100644 integration/src/bulk-b2-per-item-floor.it.ts create mode 100644 integration/src/bulk-b3-per-item-giant.it.ts create mode 100644 integration/src/bulk-export-fcfs-space.it.ts create mode 100644 integration/src/bulk-export-full-train.it.ts create mode 100644 integration/src/bulk-export-matrix.it.ts create mode 100644 integration/src/bulk-export-pay-or-lose.it.ts create mode 100644 integration/src/bulk-import-full-train.it.ts create mode 100644 integration/src/bulk-import-matrix.it.ts create mode 100644 integration/src/bulk-import-split-promote.it.ts create mode 100644 integration/src/bulk-import-waiting-expiry.it.ts create mode 100644 integration/src/bulk-import-window-reopen.it.ts create mode 100644 integration/src/cbe-bill.it.ts create mode 100644 integration/src/client.ts create mode 100644 integration/src/concurrency.it.ts create mode 100644 integration/src/flows.ts create mode 100644 integration/src/g1-s1-expiry-promotes-waitlist.it.ts create mode 100644 integration/src/g1-s2-exact-fill.it.ts create mode 100644 integration/src/g1-s3-underfill-day-open.it.ts create mode 100644 integration/src/g1-s4-split-closes-gap.it.ts create mode 100644 integration/src/g1-s5-cascading-expiry.it.ts create mode 100644 integration/src/g1-s6-s8-offers-government-tiers.it.ts create mode 100644 integration/src/global-setup.ts create mode 100644 integration/src/payment-failure.it.ts create mode 100644 integration/src/payment-happy.it.ts create mode 100644 integration/tsconfig.json create mode 100644 integration/vitest.config.ts diff --git a/docker-compose.it.yaml b/docker-compose.it.yaml new file mode 100644 index 000000000..3fb1b16a2 --- /dev/null +++ b/docker-compose.it.yaml @@ -0,0 +1,165 @@ +# EDR Freight — API integration stack (headless). +# +# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio, +# mocks, freight-api), except the payment microservice is REAL here instead of +# `payment-mock-e2e`, and only the bank gateways are stubbed: +# +# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it +# ^ │ +# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer) +# +# Its own compose project (`name:` below overrides the base) and its own host +# ports, so it can run side by side with the Cypress e2e stack. +# +# node integration/scripts/it.mjs up|test|down|logs +# +# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is +# an OVERLAY and needs the base file first: +# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ... +name: edr-freight-it + +services: + # Outbox transport. The payment API publishes payment.succeeded/failed here + # and freight consumes it — the production path. Copied from the passenger + # harness (e2e/docker-compose.yml). + rabbitmq-it: + image: rabbitmq:3-management + environment: + RABBITMQ_DEFAULT_USER: edr + RABBITMQ_DEFAULT_PASS: edr_secret + RABBITMQ_DEFAULT_VHOST: payment + ports: + - "${IT_RABBIT_PORT:-5772}:5672" + - "${IT_RABBIT_UI_PORT:-15772}:15672" + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 5s + timeout: 5s + retries: 20 + + # Stand-in for every bank/wallet gateway the payment API talks to, plus a + # control plane the tests drive (force a provider to fail/hang, fire a + # correctly-signed webhook, read back what was called). See + # integration/gateway-mock/server.js. + gateway-mock-it: + image: node:20-alpine + volumes: + - ./integration/gateway-mock:/app:ro + working_dir: /app + environment: + PORT: "4600" + # Same secrets the payment API gets — so webhooks the mock signs pass the + # API's REAL signature verification instead of bypassing it. + CBE_SECRET_KEY: it-cbe-secret + CBE_MERCHANT_ID: it-cbe-merchant + PAYMENT_API_URL: http://payment-api-it:3003 + command: ["node", "server.js"] + ports: + - "${IT_GATEWAY_PORT:-4600}:4600" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 3s + timeout: 3s + retries: 10 + + payment-api-it: + build: + context: . + dockerfile: apps/edr-payment-api/Dockerfile + secrets: + - npmrc + depends_on: + postgres-freight-e2e: + condition: service_healthy + rabbitmq-it: + condition: service_healthy + gateway-mock-it: + condition: service_healthy + environment: + PORT: "3003" + NODE_ENV: test + # Payment tables live in their own schema of the same throwaway DB; + # main.ts ensurePaymentSchema() creates it, migrationsRun does the rest. + DB_HOST: postgres-freight-e2e + DB_PORT: "5432" + DB_USER: edr_e2e + DB_PASSWORD: edr_e2e + DB_NAME: edr_freight_e2e + DB_SCHEMA: edr_payment + # Same token freight already uses in the base stack. + SERVICE_AUTH_TOKEN: e2e-service-token + PUBLISHER_TRANSPORT: rabbitmq + PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment + # HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http). + PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid + # Fast relay + sweep so retry/reconciliation are observable inside a test + # rather than a minute later. + OUTBOX_RELAY_INTERVAL_MS: "1000" + RECONCILE_STALE_AFTER_MS: "5000" + # Every gateway points at the one mock. Paths are per-provider prefixes. + CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr + CBE_MERCHANT_ID: it-cbe-merchant + CBE_SECRET_KEY: it-cbe-secret + CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr + CBE_RETURN_URL: http://localhost/return + TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr + TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web + TELEBIRR_FABRIC_APP_ID: it-fabric + TELEBIRR_APP_SECRET: it-secret + TELEBIRR_MERCHANT_APP_ID: it-merchant-app + TELEBIRR_MERCHANT_CODE: "999999" + TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr + # Telebirr PSS-signs every request object — a throwaway key generated per + # launch by it.mjs (nothing key-shaped lives in git). + TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY} + EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr + DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney + CARD_BASE_URL: http://gateway-mock-it:4600/card + WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi + CAC_BASE_URL: http://gateway-mock-it:4600/cac + CAC_USERNAME: it-cac + CAC_PASSWORD: it-cac + CAC_APP_KEY: it-cac-key + CAC_API_KEY: it-cac-api + CAC_COMPANY_SERVICES_ID: "1" + # Inbound CBE Unified Bill — we are the biller; bill-query hops back into + # the freight API, so this direction runs real code on both sides. + CBE_BILL_ENABLED: "true" + CBE_BILL_CLIENT_ID: it-cbe-bill + CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret + CBE_BILL_JWT_SECRET: it-cbe-bill-jwt + FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api + ports: + - "${IT_PAYMENT_PORT:-3113}:3003" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 5s + timeout: 5s + retries: 12 + start_period: 40s + + # Base-stack service, re-pointed at the real payment API. + freight-api-e2e: + depends_on: + payment-api-it: + condition: service_healthy + environment: + PAYMENT_API_URL: http://payment-api-it:3003 + # Freight's payment module skips RabbitMQModule entirely when this is + # unset (payment.module.ts) — without it, outbox events never arrive. + PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment + # RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email + # clients, which must remain off. The payment consumer is wired by + # PAYMENT_RABBITMQ_URL alone. diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 000000000..65550aa0f --- /dev/null +++ b/integration/README.md @@ -0,0 +1,156 @@ +# Freight API integration suite + +Headless, API-level tests for the freight API running against the **real** +`edr-payment-api`. Only the bank/wallet gateways are stubbed. + +``` +pnpm it:up # build + start the stack (first run ~5 min) +pnpm it:test # vitest run (auto-ups the stack if needed) +pnpm it:test -- --reporter=basic src/payment-happy.it.ts +pnpm it:logs payment-api-it +pnpm it:down # -v, wipes the throwaway DB +``` + +## Stack + +`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml` — same +freight API, Postgres (tmpfs), MinIO, Fayda/eTrade mocks; plus RabbitMQ, the +real payment API, and one gateway mock. It is a separate compose project +(`edr-freight-it`) on offset ports, so the Cypress e2e stack can run alongside. + +``` +freight-api-e2e :3111 ──HTTP──> payment-api-it :3113 ──HTTP──> gateway-mock-it :4600 + ^ │ + └────────── RabbitMQ :5772 ─────┘ (outbox → payment.events → consumer) +``` + +Never `docker compose -f docker-compose.it.yaml` on its own — it needs the base +file first. Use `it.mjs`. + +## Gateway mock + +`gateway-mock/server.js` — one zero-dep `node:http` process serving every +provider under a path prefix, plus a control plane the tests drive: + +| call | effect | +| --- | --- | +| `POST /__control/provider/:name` `{mode, times}` | `ok` / `fail` / `timeout` / `pending` / `paid` | +| `POST /__control/webhook` `{merchantOrderId, status, eventId, signature}` | fires a **correctly signed** callback at the payment API | +| `POST /__control/settle` `{merchantOrderId}` | pays at the bank with no callback (reconciliation path) | +| `GET /__control/calls` | every inbound provider call | +| `POST /__control/reset` | clear modes, orders and calls | + +Signatures are real: the mock shares `CBE_SECRET_KEY` with the API, so +`verifyWebhookSignature` runs for real and `signature: "bad"` is a genuine +negative test. The suite drives **CBE Birr** end to end (plain HMAC, no key +material); other providers answer a generic stub until a scenario needs them. + +Editing `server.js` needs a container restart (`docker compose … restart +gateway-mock-it`) — the code is a read-only mount, not baked into an image. + +## Files + +| file | covers | +| --- | --- | +| `src/payment-happy.it.ts` | initiate → webhook → outbox → broker → invoice PAID → booking advances | +| `src/payment-failure.it.ts` | provider down, decline, forged signature, reconciliation sweep, `unverifiable`, late capture | +| `src/concurrency.it.ts` | duplicate callbacks, two intents on one invoice, wagon budget, pay-window gate | +| `src/authz.it.ts` | cross-tenant isolation, login audience, service-token gates | +| `src/cbe-bill.it.ts` | inbound CBE Unified Bill: token → query (hops into freight) → payment | +| `src/bulk-import-full-train.it.ts` | six wheat bookings fill 54 wagons, then gate pass → T1 → dispatch → corridor → arrival → customs tail | +| `src/bulk-import-waiting-expiry.it.ts` | exact-fill trio selected, waiting three expire with the day | +| `src/bulk-import-split-promote.it.ts` | partial offer, split on settlement, expiry promotion, exact-remainder rebooking | +| `src/bulk-import-window-reopen.it.ts` | nobody pays → cycle 2 opens on the same train | +| `src/bulk-import-matrix.it.ts` | no-window day, sub-corridor, ride-along, whole-train giant | +| `src/bulk-export-full-train.it.ts` | FCFS accept = reservation, deadlines clamped to window close, export tail | +| `src/bulk-export-fcfs-space.it.ts` | reservations hold capacity, whole-or-nothing giant | +| `src/bulk-export-pay-or-lose.it.ts` | expiry frees space; window close expires the unpaid, no reopen | +| `src/bulk-export-matrix.it.ts` | mid-route boarding, leg occupancy, ride-along, sibling train windows | +| `src/bulk-b1-priority-expiry-refill.it.ts` | rule-engine priority band, expiry refill | +| `src/bulk-b2-per-item-floor.it.ts` | PER_ITEM wagon floor vs tonnage math | +| `src/bulk-b3-per-item-giant.it.ts` | PER_ITEM giant + line quantities (pins two defects) | +| `src/g1-s1-expiry-promotes-waitlist.it.ts` | 53-wagon built train: expiry frees exactly the waiting list's space | +| `src/g1-s2-exact-fill.it.ts` | exact 53/53 fill, every one of 68 containers mapped to a slot | +| `src/g1-s3-underfill-day-open.it.ts` | 28/53 is NOT FULL — the day still offers 25 wagons | +| `src/g1-s4-split-closes-gap.it.ts` | container split closes the last 3-wagon gap, 14-box remainder | +| `src/g1-s5-cascading-expiry.it.ts` | one settle promotes twice; expiries terminal, invoices closed | +| `src/g1-s6-s8-offers-government-tiers.it.ts` | ignored offer, government preemption, USD/customs/plain tiers | +| `src/flows.ts` | freight business steps, ported from `e2e/freight/cypress/e2e/flows/import-utils.ts` | + +## Findings pinned by these tests + +Where the platform's live behaviour differs from the scenario, the test asserts +what it actually does and says so in a comment, so a fix fails loudly: + +- **Refill never supersedes an open partial offer** (`bulk-b1`, `g1-s5`). After + the giant expires and frees 28 wagons, the offered booking is re-selected but + keeps its stale 16-wagon offer; `applySplit` applies it, so the customer ships + 16 of 20 with room to spare. `g1-s5` pins the container half: an expiry frees + 8 more wagons and the 2-wagon offer beside them is never resized. +- **PER_ITEM bookings never get a partial offer** (`bulk-b3`). `sizeOffer` sizes + bulk offers by weight off `cargoTotalWeightVgm`, which for PER_ITEM holds the + ITEM COUNT — a 240-auto booking needing 60 wagons looks like 4, gets no offer, + allocates nothing, and expires with the day. +- **The contract booking path drops bulk `hazardousQuantity` / `reeferQuantity`** + (`bulk-b3`). Only `POST /api/bookings` maps and clamps them. +- **Paid intercity ride-alongs are unpinned back to the pool** (both matrices) — + staff must place them again. The Cypress twin never sees this because its + staff mark-paid shortcut leaves the reservation pinned. +- **Bulk priority is recomputed at doc-review** (`bulk-b1`), so writing + `priority_score` directly is a no-op; ranking has to come from a WAGON + priority config. +- **Freight sends a dev-shortcut amount** (1 minor unit, 10 for CAC) for every + non-`CBE_BILL` provider, with no short-payment guard. +- **Government preemption cannot reach a FULL train** (`g1-s6-s8`). `isFillable` + rejects a schedule whose `booking_window_status` is FULL before any budget or + victim is considered, and `refreshWindowStatus` re-derives that flag from live + capacity — so a genuinely full train is skipped and no commercial booking is + ever displaced. S7 therefore commits 52 of 53 slots. +- **The CUSTOMS priority band is dead in the shipped fixture.** It applies only + when the booking's SERVICE TYPE has `includes_customs`, and the corridor seed + ships one service type that does not — so the two CUSTOMS bands never score. + `seed-customs-service-type.sql` adds `RAIL_CUSTOMS` so the tier can be tested. +- **A customs SERVICE TYPE without a customs CONTRACT cannot finalize + clearance.** `finalizeClearance` looks up `clearance_output__`, + which the seeder deliberately leaves commented out, so `getByCode` 404s. Only + the phased path (`customs_clearing_enabled` on the contract) avoids it — which + is why S8's customs tenant is Path B. + +## Gotchas + +- **One unpaid hold per company.** `assertNoUnpaidHold` blocks a company with a + `SELECTED_FOR_BATCH` booking from creating another. Every file starts with + `releaseUnpaidHolds()`, which also hard-deletes retired + `train_schedule_bookings` rows (`booking_id` is UNIQUE and the constraint + ignores `deleted_at`, so a soft-unlinked booking can never be re-batched). +- **Arrange is slow.** Contract → booking → clearance → ops accept → batch is + 30–60s of real API work per booking, so files share one schedule day. +- Files run sequentially (one DB); concurrency is exercised inside a test with + `Promise.all`. +- **A retired fixture keeps its shipment day at its peril.** + `rescueStrandedPaidForDay` sweeps every unlinked booking whose + `payment_status` is PAID and whose `scheduled_date` falls on the day being + filled, and re-places it on the fresh train. A previous run's paid bookings + therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, until + `releaseUnpaidHolds` / `resetCorridorDay` started nulling `scheduled_date`. +- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and + REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for + DONE there waits forever — use `pollCycleConcluded`. +- **Wagon stock is finite and shared.** Paid bookings keep their wagons, so + `releaseUnpaidHolds()` also frees every earlier `CTR-IT-%` allocation — + without it the fifth or sixth file on a warm stack silently gets a short + consist. +- **One tenant per booking.** `seedTenantContracts` mints a company per booking + because a company may hold only one unpaid reservation at a time; staff book + and pay on their behalf, which is also the real Path B flow. +- **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a + cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here) + every fill pass. A built train's coupled consist wins outright, so + `seed-g1-train.sql`'s 53 wagons ARE the capacity — the number every G1 + scenario's arithmetic is written in. `createBuiltTrainSchedule` asserts it. +- **Customs bookings walk the phased chain** (`clearBookingPhasedCustoms`): + transit assignee → declaration draft → accept → declaration → duty → + transit permit → pre-clearance → delivery order. `clearance/finalize` refuses + them outright. +- Freight sends a dev-shortcut amount (1 minor unit, 10 for CAC) for every + non-`CBE_BILL` provider. The tests assert that as-is. diff --git a/integration/gateway-mock/server.js b/integration/gateway-mock/server.js new file mode 100644 index 000000000..4747d16ed --- /dev/null +++ b/integration/gateway-mock/server.js @@ -0,0 +1,299 @@ +// Stand-in for every bank/wallet gateway the payment API calls, plus a control +// plane the integration tests drive. +// +// WHY ONE PROCESS +// +// Each provider's base URL is env-configurable (packages/payment-providers/…), +// so pointing them all at one server with a per-provider path prefix stubs the +// whole outbound surface without touching a line of app code. The payment API +// itself, its state machine, its webhook pipeline and its signature checks all +// run for real. +// +// Signatures are REAL: this server holds the same CBE_SECRET_KEY the API does +// and signs the callbacks it fires, so the API's verifyWebhookSignature runs in +// anger instead of being bypassed. That also makes the negative test possible — +// ask for a bad signature and the API must refuse to move any money. +// +// No dependencies (node:http + node:crypto), same shape as e2e/freight/*-mock. +const http = require("node:http"); +const crypto = require("node:crypto"); + +const PORT = Number(process.env.PORT || 4600); +const PAYMENT_API_URL = process.env.PAYMENT_API_URL || "http://payment-api-it:3003"; +const CBE_SECRET = process.env.CBE_SECRET_KEY || "it-cbe-secret"; +const CBE_MERCHANT = process.env.CBE_MERCHANT_ID || "it-cbe-merchant"; +const CAC_OTP = "123456"; + +/** + * Per-provider behaviour, set by POST /__control/provider/:name. + * ok — succeed (default) + * fail — answer 502, so the provider call throws inside the API + * timeout — never answer (the API's own 10s axios timeout fires) + * pending — succeed on initiate, but report "not paid yet" on every query + * paid — report SUCCESS on query without any webhook (reconciliation path) + * `remaining` counts down when set, then the provider reverts to ok. + */ +const modes = new Map(); +/** Every inbound call, for "the provider was queried exactly once" assertions. */ +let calls = []; +/** merchantOrderId → what the mock believes the payment did. */ +const orders = new Map(); + +function modeFor(provider) { + const entry = modes.get(provider); + if (!entry) return "ok"; + if (entry.remaining != null) { + if (entry.remaining <= 0) { + modes.delete(provider); + return "ok"; + } + entry.remaining -= 1; + } + return entry.mode; +} + +/** CBE Birr signs `k=v` pairs over sorted keys with HMAC-SHA256 (hex). */ +function cbeSign(data) { + const signString = Object.keys(data) + .sort() + .map((k) => `${k}=${data[k]}`) + .join("&"); + return crypto.createHmac("sha256", CBE_SECRET).update(signString).digest("hex"); +} + +async function postJson(url, body, headers = {}) { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + const text = await res.text(); + return { status: res.status, body: text }; +} + +/** + * Fire a provider callback at the payment API, signed the way the real gateway + * would. `signature: "bad"` deliberately produces a well-formed but wrong + * signature (same length — the API compares with timingSafeEqual, which throws + * on a length mismatch and would mask what we are testing). + */ +async function fireWebhook(opts) { + const { + provider = "CBE_BIRR", + merchantOrderId, + status = "SUCCESS", + transactionId, + eventId, + signature, + } = opts; + if (provider !== "CBE_BIRR") { + throw new Error(`webhook not implemented for provider ${provider}`); + } + const order = orders.get(merchantOrderId) ?? {}; + const payload = { + merchantId: CBE_MERCHANT, + merchantOrderId, + // orderId doubles as the dedupe key upstream: externalEventId is + // `${orderId}_${status}` (cbe-birr-webhook.service.ts), so a caller-supplied + // eventId is how a test replays the SAME event. + orderId: eventId ?? order.orderId ?? `CBEORD-${merchantOrderId}`, + status, + transactionId: transactionId ?? order.transactionId ?? `CBETXN-${merchantOrderId}`, + amount: order.amount ?? "1.00", + currency: order.currency ?? "ETB", + paidAt: new Date().toISOString(), + }; + payload.signature = + signature === "bad" ? crypto.randomBytes(32).toString("hex") : cbeSign(payload); + return postJson(`${PAYMENT_API_URL}/webhooks/cbe-birr`, payload); +} + +// --------------------------------------------------------------------------- +// provider routes +// --------------------------------------------------------------------------- + +/** @returns {[number, unknown] | "hang"} */ +function handleProvider(provider, path, body, url) { + const mode = modeFor(provider); + if (mode === "timeout") return "hang"; + if (mode === "fail") return [502, { error: `${provider} unavailable (forced)` }]; + + switch (`${provider}/${path}`) { + // --- CBE Birr (the suite's primary provider: plain HMAC, no key material) + case "cbe-birr/api/v1/payment/initiate": { + const orderId = `CBEORD-${body.merchantOrderId}`; + orders.set(body.merchantOrderId, { + orderId, + amount: body.amount, + currency: body.currency, + transactionId: `CBETXN-${body.merchantOrderId}`, + paid: false, + }); + return [ + 200, + { + success: true, + orderId, + paymentUrl: `http://gateway-mock-it:${PORT}/cbe-birr/pay/${orderId}`, + expiresIn: 900, + }, + ]; + } + case "cbe-birr/api/v1/payment/query": { + const order = orders.get(body.merchantOrderId); + if (!order) return [200, { success: false, status: "NOT_FOUND" }]; + const paid = mode === "paid" || order.paid; + return [ + 200, + { + success: true, + orderId: order.orderId, + status: paid ? "SUCCESS" : mode === "pending" ? "PENDING" : "PROCESSING", + transactionId: order.transactionId, + amount: order.amount, + paidAt: paid ? new Date().toISOString() : undefined, + }, + ]; + } + + // --- Telebirr (fabric token + createOrder + queryOrder) + case "telebirr/payment/v1/token": + return [200, { token: "it-fabric-token" }]; + case "telebirr/payment/v1/inapp/createOrder": { + const merchOrderId = body?.biz_content?.merch_order_id; + const prepayId = `PREPAY-${merchOrderId ?? Date.now()}`; + orders.set(merchOrderId, { orderId: prepayId, paid: false }); + return [ + 200, + { + result: "SUCCESS", + code: "0", + biz_content: { prepay_id: prepayId, merch_order_id: merchOrderId }, + }, + ]; + } + case "telebirr/payment/v1/merchant/queryOrder": { + const order = orders.get(body?.biz_content?.merch_order_id); + const paid = mode === "paid" || order?.paid; + return [ + 200, + { + result: "SUCCESS", + code: "0", + biz_content: { + order_status: paid ? "Completed" : "Paying", + trans_id: order?.orderId, + }, + }, + ]; + } + + // --- CAC Bank (OTP debit) + case "cac/paymentapi/auth/signin": + return [200, { token: "it-cac-token", expiresIn: 86400 }]; + case "cac/paymentapi/PaymentInitiateRequest": { + const id = `${Date.now()}00000`; + orders.set(String(id), { orderId: String(id), paid: false }); + return [200, { status: true, message: "OTP sent", data: { id, otpRequired: true } }]; + } + + default: + // Unimplemented gateway paths answer a generic OK rather than 404: the + // suite only drives CBE Birr / CAC end to end, and a 404 here would look + // like a bug in the API rather than an unused stub. Add real shapes when + // a scenario needs them. + calls.push({ provider, path, unimplemented: true }); + return [200, { success: true, stub: true, path: `${provider}/${path}`, url }]; + } +} + +// --------------------------------------------------------------------------- +// server +// --------------------------------------------------------------------------- + +const server = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", async () => { + const raw = Buffer.concat(chunks).toString("utf8"); + let body = {}; + try { + body = raw ? JSON.parse(raw) : {}; + } catch { + body = { raw }; + } + const send = (status, payload) => { + const json = JSON.stringify(payload ?? {}); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(json), + }); + res.end(json); + }; + + const url = new URL(req.url, "http://mock"); + const path = url.pathname.replace(/^\/+/, ""); + + // --- control plane ----------------------------------------------------- + if (path.startsWith("__control")) { + const [, action, arg] = path.split("/"); + if (action === "health") return send(200, { ok: true }); + if (action === "reset") { + modes.clear(); + orders.clear(); + calls = []; + return send(200, { ok: true }); + } + if (action === "calls") { + return send(200, { calls }); + } + if (action === "provider" && req.method === "POST") { + modes.set(arg, { mode: body.mode ?? "ok", remaining: body.times ?? null }); + console.log(`[gateway-mock] ${arg} → ${body.mode} (times=${body.times ?? "∞"})`); + return send(200, { ok: true, provider: arg, mode: body.mode }); + } + if (action === "settle" && req.method === "POST") { + // Mark the order paid at the gateway WITHOUT notifying — the payment + // API can then only learn about it by polling (reconciliation path). + const order = orders.get(body.merchantOrderId); + if (!order) return send(404, { error: "unknown merchantOrderId" }); + order.paid = true; + return send(200, { ok: true }); + } + if (action === "webhook" && req.method === "POST") { + try { + const result = await fireWebhook(body); + console.log( + `[gateway-mock] webhook ${body.merchantOrderId} ${body.status ?? "SUCCESS"} → ${result.status}`, + ); + return send(200, { ok: true, delivered: result.status, body: result.body }); + } catch (err) { + return send(500, { error: String(err) }); + } + } + return send(404, { error: `unknown control action ${action}` }); + } + + // --- gateway routes ---------------------------------------------------- + const provider = path.split("/")[0]; + const rest = path.slice(provider.length + 1); + calls.push({ provider, path: rest, method: req.method, body, at: Date.now() }); + + // CAC confirm carries the OTP; wrong code must fail the way the bank does. + if (rest.startsWith("paymentapi/") && rest.includes("Confirm")) { + const ok = String(body.otp ?? body.OTP ?? "") === CAC_OTP; + return send(200, ok + ? { status: true, data: { id: body.id, status: "SUCCESS" } } + : { status: false, message: "Invalid OTP" }); + } + + const result = handleProvider(provider, rest, body, req.url); + if (result === "hang") { + console.log(`[gateway-mock] ${provider}/${rest} → hanging (forced timeout)`); + return; // never answer; the caller's own timeout fires + } + send(result[0], result[1]); + }); +}); + +server.listen(PORT, () => console.log(`gateway-mock listening on ${PORT}`)); diff --git a/integration/package.json b/integration/package.json new file mode 100644 index 000000000..4937e3216 --- /dev/null +++ b/integration/package.json @@ -0,0 +1,21 @@ +{ + "name": "@edr/freight-integration", + "version": "0.0.0", + "private": true, + "description": "API-level integration tests for the freight API against the real payment microservice", + "type": "module", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/pg": "^8.11.0", + "@types/supertest": "^6.0.2", + "pg": "^8.13.0", + "supertest": "^7.0.0", + "typescript": "^5.5.4", + "vitest": "^2.1.2" + } +} diff --git a/integration/scripts/it.mjs b/integration/scripts/it.mjs new file mode 100644 index 000000000..a30e446b5 --- /dev/null +++ b/integration/scripts/it.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Freight integration-suite launcher. + * + * node integration/scripts/it.mjs [vitest args...] + * + * Overlays docker-compose.it.yaml on docker-compose.e2e.yaml: same freight + * stack, but the payment microservice is real and only the bank gateways are + * stubbed. Web/Cypress containers are never started — this suite is HTTP only. + * + * Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks + * can be up at once; they are separate compose projects. + * + * No dependencies — plain Node spawning `docker compose` and `pnpm`. + */ + +import { execFileSync, spawnSync } from "node:child_process"; +import { generateKeyPairSync } from "node:crypto"; +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const itDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(itDir, ".."); +const composeBase = [ + "compose", + "-f", + join(repoRoot, "docker-compose.e2e.yaml"), + "-f", + join(repoRoot, "docker-compose.it.yaml"), +]; + +/** Deliberately offset from the Cypress stack's defaults (3101/5533/9310…). */ +const PORTS = { + E2E_API_PORT: 3111, + E2E_DB_PORT: 5543, + E2E_MINIO_PORT: 9320, + E2E_MINIO_CONSOLE_PORT: 9321, + // Unused here (no web containers) but referenced by the base file's build args. + E2E_PORTAL_PORT: 5393, + E2E_BACKOFFICE_PORT: 5394, + IT_PAYMENT_PORT: 3113, + IT_GATEWAY_PORT: 4600, + IT_RABBIT_PORT: 5772, + IT_RABBIT_UI_PORT: 15772, +}; + +/** Everything the suite needs up — web + cypress are deliberately absent. */ +const SERVICES = [ + "postgres-freight-e2e", + "minio-e2e", + "minio-init-e2e", + "freight-migration-e2e", + "fayda-mock-e2e", + "etrade-mock-e2e", + // Still a base-stack dependency of freight-api-e2e (reconcile-before-expire + // has its own client); cheap to run alongside the real payment API. + "payment-mock-e2e", + "gateway-mock-it", + "rabbitmq-it", + "payment-api-it", + "freight-api-e2e", +]; + +const RUNNING = SERVICES.filter( + (s) => !["minio-init-e2e", "freight-migration-e2e"].includes(s), +); + +function fail(msg) { + console.error(`\nit: ${msg}`); + process.exit(1); +} + +function preflight() { + try { + execFileSync("docker", ["info"], { stdio: "ignore" }); + } catch { + fail("docker is not running (or not installed) — start Docker and retry."); + } + if (!existsSync(join(repoRoot, ".npmrc"))) { + fail(".npmrc missing at repo root — image builds need GitHub Packages auth for @tria-plc."); + } +} + +/** Throwaway RSA PEM — Telebirr PSS-signs every request object; the mock never + * verifies it, but the provider refuses to build a request without a real key. */ +function fakeTelebirrPrivateKey() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); +} + +/** Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 (see e2e.mjs — same reason). */ +function fakeFaydaPrivateKeyBase64() { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }); + Object.assign(jwk, { kty: "RSA", use: "sig", alg: "RS256", kid: "it-fayda-mock" }); + return Buffer.from(JSON.stringify(jwk)).toString("base64"); +} + +const env = { + ...process.env, + ...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])), + IT_API_URL: `http://localhost:${PORTS.E2E_API_PORT}`, + IT_PAYMENT_URL: `http://localhost:${PORTS.IT_PAYMENT_PORT}`, + IT_GATEWAY_URL: `http://localhost:${PORTS.IT_GATEWAY_PORT}`, + IT_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${PORTS.E2E_DB_PORT}/edr_freight_e2e`, + FAYDA_PRIVATE_KEY_BASE64: + process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(), + IT_TELEBIRR_PRIVATE_KEY: + process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey(), +}; + +function compose(args) { + const { status } = spawnSync("docker", [...composeBase, ...args], { stdio: "inherit", env }); + return status ?? 1; +} + +function stackRunning() { + try { + const out = execFileSync("docker", [...composeBase, "ps", "--services", "--status", "running"], { + encoding: "utf8", + env, + stdio: ["ignore", "pipe", "ignore"], + }); + const running = new Set(out.split("\n").filter(Boolean)); + return RUNNING.every((s) => running.has(s)); + } catch { + return false; + } +} + +function up() { + preflight(); + console.log( + `it: starting stack — freight :${PORTS.E2E_API_PORT} payment :${PORTS.IT_PAYMENT_PORT} ` + + `gateway :${PORTS.IT_GATEWAY_PORT} db :${PORTS.E2E_DB_PORT}`, + ); + if (compose(["up", "-d", "--build", "--wait", ...SERVICES]) !== 0) { + fail( + "stack failed to become healthy. Inspect with:\n" + + " node integration/scripts/it.mjs logs payment-api-it", + ); + } +} + +const [cmd, ...extra] = process.argv.slice(2); + +switch (cmd) { + case "up": + up(); + break; + case "test": { + if (!stackRunning()) up(); + const { status } = spawnSync( + "pnpm", + ["--filter", "@edr/freight-integration", "run", "test", ...extra], + { cwd: repoRoot, stdio: "inherit", env }, + ); + process.exit(status ?? 1); + } + case "logs": + process.exit(compose(["logs", "--tail", "200", ...extra])); + break; + case "down": + process.exit(compose(["down", "-v", "--remove-orphans"])); + break; + default: + fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`); +} diff --git a/integration/sql/seed-company-b.sql b/integration/sql/seed-company-b.sql new file mode 100644 index 000000000..1e61e9593 --- /dev/null +++ b/integration/sql/seed-company-b.sql @@ -0,0 +1,34 @@ +-- Second tenant for the integration suite: user2@gmail.com gets its own ACTIVE +-- company + approved importer profile, mirroring seed-company.sql (which only +-- sets up user@gmail.com). Two real tenants are what make the cross-tenant +-- isolation and the "two companies race for the same train" scenarios honest. +-- Idempotent; TIN is the key. + +INSERT INTO freight.companies + (id, name, type, status, tin, fan_number, country, address, phone, email, + nationality, kind, attributes) +SELECT gen_random_uuid(), 'IT Freight Partners PLC', 'customer', 'active', + '0102030406', '1234567890123457', 'Ethiopia', 'Adama, Ethiopia', + '+251911000011', 'ops@it-partners.test', 'ethiopian', 'commercial', + '{"contactPersonName":"IT Contact","contactPersonPhone":"+251911000012","generalManagerName":"IT GM","generalManagerEmail":"gm@it-partners.test","generalManagerPhone":"+251911000013"}'::jsonb +WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030406'); + +INSERT INTO freight.company_profiles (id, company_id, type, status, reference) +SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-IT-0002' +FROM freight.companies c +WHERE c.tin = '0102030406' + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles p + WHERE p.company_id = c.id AND p.type = 'importer' + ); + +INSERT INTO freight.external_profiles + (id, user_id, company_id, first_name, last_name, is_primary_contact, + onboarding_step, onboarding_completed) +SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User2', true, 'done', true +FROM iam.users u +JOIN freight.companies c ON c.tin = '0102030406' +WHERE u.email = 'user2@gmail.com' + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id + ); diff --git a/integration/sql/seed-customs-service-type.sql b/integration/sql/seed-customs-service-type.sql new file mode 100644 index 000000000..6408486ba --- /dev/null +++ b/integration/sql/seed-customs-service-type.sql @@ -0,0 +1,17 @@ +-- A service type whose bundle INCLUDES customs, for the priority-tier scenario +-- (g1-s6-s8, S8). Idempotent. +-- +-- The rule engine's CUSTOMS priority band only applies when the booking's +-- service type has `includes_customs = true` (rule-engine.service.ts:251), and +-- the corridor fixture ships exactly one service type — RAIL, which does not. +-- Without this row the customs tier can never fire and "customs outranks plain" +-- is untestable: both bookings score the same. +-- +-- Contracts opt in with seedContract({ serviceTypeCode: 'RAIL_CUSTOMS' }). +INSERT INTO freight.service_types + (code, service_name, description, includes_customs, is_active, display_order) +SELECT 'RAIL_CUSTOMS', 'Rail Transport + Customs Clearance', + 'IT fixture: the customs-bundled service tier', true, true, 2 +WHERE NOT EXISTS ( + SELECT 1 FROM freight.service_types WHERE code = 'RAIL_CUSTOMS' +); diff --git a/integration/src/authz.it.ts b/integration/src/authz.it.ts new file mode 100644 index 000000000..8af26f720 --- /dev/null +++ b/integration/src/authz.it.ts @@ -0,0 +1,92 @@ +/** + * Who is allowed to touch a payment. Cheap to run (no booking chain), and the + * failures here are the expensive kind: a tenant reading another tenant's + * invoice, or an unauthenticated caller marking one paid. + */ +import { afterAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import { + API, + PAYMENT_API, + api, + closeDb, + customerA, + customerB, + db, + login, + payment, +} from "./client"; + +describe("payment authorization boundaries", () => { + afterAll(closeDb); + + it("hides one tenant's invoice from the other", async () => { + const rows = await db<{ id: string; company_id: string }>( + `SELECT i.id, i.company_id FROM freight.invoices i + JOIN freight.companies c ON c.id = i.company_id + WHERE c.tin = '0102030405' AND i.deleted_at IS NULL + ORDER BY i.created_at DESC LIMIT 1`, + ); + if (!rows[0]) return; // nothing billed yet in this run — payment files cover it + const res = await api(customerB, "get", `/api/billing/my-invoices/${rows[0].id}`); + expect([403, 404]).toContain(res.status); + }); + + it("refuses to let one tenant pay the other's invoice", async () => { + const rows = await db<{ id: string }>( + `SELECT i.id FROM freight.invoices i + JOIN freight.companies c ON c.id = i.company_id + WHERE c.tin = '0102030405' AND i.status <> 'PAID' AND i.deleted_at IS NULL + ORDER BY i.created_at DESC LIMIT 1`, + ); + if (!rows[0]) return; + const res = await api(customerB, "post", `/api/billing/my-invoices/${rows[0].id}/pay`, { + method: "CBE_BIRR", + platform: "web", + }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it("keeps a portal customer out of backoffice payment operations", async () => { + const res = await api(customerA, "get", "/api/billing/invoices"); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it("rejects a portal account on the backoffice login audience", async () => { + const res = await login(customerA, "12345678", "backoffice"); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + it("requires the service token on freight's mark-paid callback", async () => { + const body = { + version: 1, + eventId: "authz-probe", + eventType: "payment.succeeded", + occurredAt: new Date().toISOString(), + service: "FREIGHT", + intentId: "00000000-0000-0000-0000-000000000000", + referenceType: "SHIPMENT", + referenceId: "00000000-0000-0000-0000-000000000000", + provider: "CBE_BIRR", + amountMinor: 1, + currency: "ETB", + }; + const res = await request(API).post("/api/internal/payments/mark-paid").send(body); + expect([401, 403]).toContain(res.status); + }); + + it("requires the service token on the payment API's internal surface", async () => { + const res = await request(PAYMENT_API).get("/payments/intents?service=FREIGHT"); + expect([400, 401, 403]).toContain(res.status); + + // …and accepts it when present (400 = bad query, not an auth failure). + const withToken = await payment("get", "/payments/intents?service=FREIGHT"); + expect([401, 403]).not.toContain(withToken.status); + }); + + it("leaves the provider webhook surface public — trust is the signature", async () => { + // A garbage payload must be acked, not 401'd: providers do not authenticate. + const res = await request(PAYMENT_API).post("/webhooks/cbe-birr").send({ nonsense: true }); + expect(res.status).toBe(200); + }); +}); diff --git a/integration/src/bulk-b1-priority-expiry-refill.it.ts b/integration/src/bulk-b1-priority-expiry-refill.it.ts new file mode 100644 index 000000000..fb1d347b3 --- /dev/null +++ b/integration/src/bulk-b1-priority-expiry-refill.it.ts @@ -0,0 +1,180 @@ +/** + * BULK B1 — staff priority decides who rides; expiry refill promotes the + * offered booking WHOLE. + * + * Three wheat bookings that cannot all fit a 54-wagon CW4 train: + * BP1 1 960 T = 28 w (commercial giant) + * BP2 1 400 T = 20 w (commercial) + * BP3 700 T = 10 w (relief cargo — staff rank it FIRST) + * + * 58 wagons chase 54. With BP3 on top the batch reserves BP3 + BP1 whole + * (38 w) and leaves BP2 a whole-wagon offer for the remaining 16. BP1 then + * misses its pay window: its 28 wagons come back and the refill round must + * promote BP2 WHOLE — superseding the 16-wagon offer. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { api, apiOk, closeDb, db, gateway, superAdmin } from "./client"; +import { + bookBulkReady, + allocatedWagons, + bookingRow, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + eatDayStr, + ensureCorridorRoute, + extendPayWindow, + expectWagonType, + forceReservationExpiry, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollPartialOffer, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(40); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +/** + * Booked in arrival order; the RANKING comes from a real priority rule. + * + * NOTE — the Cypress twin ranks these by writing `priority_score` directly. + * That lever is dead for BULK: `recomputeBulkPriorities` + * (booking-batch.service.ts) re-derives every bulk booking's score from the + * rule engine when doc review closes, overwriting anything hand-written. So + * this file configures the product's own lever instead — a WAGON priority band + * that scores 1–10-wagon bookings above everything else, which is exactly how + * staff would push relief cargo to the front. + */ +const BOOKINGS = [ + { suffix: "BP1", tons: 1960, wagons: 28 }, // commercial giant + { suffix: "BP2", tons: 1400, wagons: 20 }, // does not fit whole → offered 16 + { suffix: "BP3", tons: 700, wagons: 10 }, // relief cargo — ranked first by the rule +]; + +describe("bulk b1: priority ordering and expiry refill", () => { + const booking = new Map(); + let scheduleId: string; + let priorityConfigId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + BOOKINGS.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"], + }) + ).id; + await forceWindowOpen(scheduleId, 60); + + // Staff rank relief-sized cargo first: a WAGON band worth the maximum 50 + // points for 1–10 wagons. Ranges must be contiguous from 1, and this is + // the first WAGON rule in the stack. Removed again in afterAll so the + // other bulk files keep the default (unranked) engine. + const cfg = await apiOk(superAdmin, "post", "/api/priority-configs", { + type: "WAGON", + label: "IT relief cargo 1-10 wagons", + minWagonCount: 1, + maxWagonCount: 10, + scorePoints: 50, + isActive: true, + }); + priorityConfigId = (cfg.body?.data?.id ?? cfg.body?.id) as string; + expect(priorityConfigId, "priority config created").toBeTruthy(); + + for (const b of BOOKINGS) { + booking.set( + b.suffix, + await bookBulkReady({ + contractId: contracts.get(b.suffix)!, + tons: b.tons, + scheduledDate: BOOKING_DAY, + }), + ); + } + }, 1_800_000); + + afterAll(async () => { + if (priorityConfigId) { + await api(superAdmin, "delete", `/api/priority-configs/${priorityConfigId}`); + } + await closeDb(); + }); + + it("ranks the relief cargo first — it and the giant reserve whole, the third is offered 16", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + // The batch re-scored the pool from the rule: relief 50, the other two 0. + const scores = await db<{ id: string; priority_score: string }>( + `SELECT id, priority_score FROM freight.bookings WHERE id = ANY($1::uuid[])`, + [[...booking.values()]], + ); + const scoreOf = (suffix: string) => + Number(scores.find((r) => r.id === booking.get(suffix))?.priority_score ?? 0); + expect(scoreOf("BP3"), "relief cargo outranks the commercial pair").toBeGreaterThan( + Math.max(scoreOf("BP1"), scoreOf("BP2")), + ); + + for (const suffix of ["BP3", "BP1"]) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + const offer = await pollPartialOffer(booking.get("BP2")!); + expect(Number(offer.offered_wagons), "BP2 offered the 16-wagon leftover").toBe(16); + }); + + it("the relief cargo pays and rides CW4; the giant misses its pay window", async () => { + await extendPayWindow(scheduleId, [booking.get("BP3")!]); + await payViaGateway(booking.get("BP3")!); + await pollAllocations(booking.get("BP3")!, 10); + await expectWagonType(booking.get("BP3")!, "CW4", 10); + + await forceReservationExpiry(booking.get("BP1")!); + await pollBookingStatus(booking.get("BP1")!, "EXPIRED", 40); + }); + + it("the refill round re-selects the offered booking — but its 16-wagon offer still stands", async () => { + await pollBookingStatus(booking.get("BP2")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40); + await extendPayWindow(scheduleId, [booking.get("BP2")!]); + await payViaGateway(booking.get("BP2")!); + await pollAllocations(booking.get("BP2")!, 16); + + const bp2 = await bookingRow(booking.get("BP2")!); + + // FINDING — the scenario expects the refill to promote BP2 WHOLE (20 w) + // into the 28 wagons the expired giant just freed, superseding its + // 16-wagon offer. It does not: the refill flips the booking back to + // reserved but never issues a replacement offer, and `applySplit` then + // applies the ONLY open offer — the stale 16-wagon one + // (booking-split.service.ts: an offer is superseded only when a NEW offer + // is created). The customer ships 16 of 20 wagons with room to spare. + // This test pins today's behaviour so the fix flips it loudly. + expect(Number(bp2.wagons_required), "rides the stale offer, not the freed 20").toBe(16); + expect(bp2.is_split, "split against the stale offer").toBe(true); + + const [offers] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.booking_batch_offers + WHERE booking_id = $1 AND deleted_at IS NULL`, + [booking.get("BP2")!], + ); + expect(Number(offers.n), "no replacement offer was issued after the refill").toBe(1); + + // …and the train really did have the room: 10 (relief) + 16 = 26 of 54. + expect(await allocatedWagons(scheduleId), "28 wagons left unused").toBe(26); + }); +}); diff --git a/integration/src/bulk-b2-per-item-floor.it.ts b/integration/src/bulk-b2-per-item-floor.it.ts new file mode 100644 index 000000000..6b6f8aef9 --- /dev/null +++ b/integration/src/bulk-b2-per-item-floor.it.ts @@ -0,0 +1,141 @@ +/** + * BULK B2 — break-bulk PER_ITEM wagon math on the CW4 fleet (70 T capacity). + * + * Cargo from seed-bulk-items.sql: + * E2E_IMP_AUTO automobiles — items-per-wagon floor of 4 + * E2E_IMP_MACHINE machinery — no floor, tonnage-only fallback + * + * Three verdicts of the wagon-demand rule, end to end: + * BA1 16 autos @2.5 T (40 T) → the floor binds: 4 wagons (tonnage said 1) + * BA2 12 machines @20 T (240 T) → tonnage binds: 3/wagon → 4 wagons + * BA3 216 autos (540 T) → exactly 54 wagons: FULL from one booking, no split + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + bookBulkItemsReady, + bookingRow, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + extendPayWindow, + expectWagonType, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(41); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const FULL_DEPARTURE = departureAt(42); +const FULL_DAY = eatDayStr(FULL_DEPARTURE); +const STAMP = String(Date.now()); + +describe("bulk b2: PER_ITEM floor vs tonnage wagon math", () => { + const booking = new Map(); + let contracts: Map; + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + await resetCorridorDay(FULL_DEPARTURE); + + contracts = await seedTenantContracts( + STAMP, + ["BA1", "BA2", "BA3"].map((suffix) => ({ suffix, freight: "BULK" as const })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"], + }) + ).id; + await forceWindowOpen(scheduleId, 60); + + booking.set( + "BA1", + await bookBulkItemsReady({ + contractId: contracts.get("BA1")!, + cargoCode: "E2E_IMP_AUTO", + items: 16, + tons: 40, + scheduledDate: BOOKING_DAY, + }), + ); + booking.set( + "BA2", + await bookBulkItemsReady({ + contractId: contracts.get("BA2")!, + cargoCode: "E2E_IMP_MACHINE", + items: 12, + tons: 240, + scheduledDate: BOOKING_DAY, + }), + ); + }, 1_800_000); + + afterAll(closeDb); + + it("the 4-per-wagon floor binds for 16 autos → 4 CW4 wagons, not 1", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ["BA1", "BA2"]) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + + await extendPayWindow(scheduleId, [booking.get("BA1")!, booking.get("BA2")!]); + await payViaGateway(booking.get("BA1")!); + await pollAllocations(booking.get("BA1")!, 4); + await expectWagonType(booking.get("BA1")!, "CW4", 4); + }); + + it("machinery has no floor — 12 items @20 T take 4 wagons on tonnage alone", async () => { + await payViaGateway(booking.get("BA2")!); + await pollAllocations(booking.get("BA2")!, 4); + await expectWagonType(booking.get("BA2")!, "CW4", 4); + }); + + it("216 autos = exactly 54 wagons: FULL from one break-bulk booking, no split", async () => { + const fullSchedule = await createSchedule({ + departure: FULL_DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"], + }); + await forceWindowOpen(fullSchedule.id, 45); + + const ba3 = await bookBulkItemsReady({ + contractId: contracts.get("BA3")!, + cargoCode: "E2E_IMP_AUTO", + items: 216, + tons: 540, + scheduledDate: FULL_DAY, + }); + await closeBookingWindow(fullSchedule.id); + await completeDocReview(fullSchedule.id); + await pollBookingStatus(ba3, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + + await payViaGateway(ba3); + await pollAllocations(ba3, 54); + expect((await bookingRow(ba3)).is_split, "BA3 rides whole, not split").not.toBe(true); + + await endPaymentPhase(fullSchedule.id); + await pollWindow( + fullSchedule.id, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL + DONE", + ); + }, 900_000); +}); diff --git a/integration/src/bulk-b3-per-item-giant.it.ts b/integration/src/bulk-b3-per-item-giant.it.ts new file mode 100644 index 000000000..90289999c --- /dev/null +++ b/integration/src/bulk-b3-per-item-giant.it.ts @@ -0,0 +1,173 @@ +/** + * BULK B3 — PER_ITEM giant split and line quantities. + * + * The scenario: 240 automobiles (600 T) on a 54-wagon CW4 train. The + * 4-per-wagon floor needs 60 wagons, so the batch should offer the whole + * consist (216 autos / 54 wagons), the settlement should apply the split, and + * the 24-auto remainder should have to be rebooked exactly. Plus two field + * checks: hazardousQuantity above the line count clamps, reeferQuantity is + * stored. + * + * WHAT ACTUALLY HAPPENS — two defects this file pins: + * + * 1. PER_ITEM bookings never get a partial offer. `sizeOffer` + * (booking-split.service.ts) sizes a bulk offer by WEIGHT off + * `cargoTotalWeightVgm` — but for PER_ITEM cargo that column holds the + * ITEM COUNT (240), not tonnage (600, kept in `bulk_total_weight_tons`). + * 240 "tons" fits 54 wagons, so no offer is made; the booking is reserved + * without a wagon count, allocates nothing, and silently expires with the + * day. The 24-auto remainder step therefore cannot happen at all. + * + * 2. The contract booking path DROPS per-line `hazardousQuantity` / + * `reeferQuantity` for bulk. Only the direct booking path + * (`POST /api/bookings`, bookings.service.ts) maps them onto + * `bulk_hazardous_quantity` / `bulk_reefer_quantity` — and only that path + * clamps them to the cargo amount. + * + * Both are asserted as they behave today, so a fix fails here loudly. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, db, gateway } from "./client"; +import { + bookBulkItems, + bookBulkItemsReady, + bookingFor, + bookingRow, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollPartialOffer, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, +} from "./flows"; + +const GIANT_DEPARTURE = departureAt(43); +const GIANT_DAY = eatDayStr(GIANT_DEPARTURE); +const REMAINDER_DEPARTURE = departureAt(44); +const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE); +const STAMP = String(Date.now()); + +describe("bulk b3: per-item giant offer and line quantities", () => { + let contracts: Map; + let giantScheduleId: string; + let bg1: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(GIANT_DEPARTURE); + await resetCorridorDay(REMAINDER_DEPARTURE); + + contracts = await seedTenantContracts( + STAMP, + ["BG1", "BQ1", "BQ2"].map((suffix) => ({ suffix, freight: "BULK" as const })), + ); + giantScheduleId = ( + await createSchedule({ + departure: GIANT_DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"], + }) + ).id; + await forceWindowOpen(giantScheduleId, 45); + + bg1 = await bookBulkItemsReady({ + contractId: contracts.get("BG1")!, + cargoCode: "E2E_IMP_AUTO", + items: 240, + tons: 600, + scheduledDate: GIANT_DAY, + }); + }, 1_800_000); + + afterAll(closeDb); + + it("a 240-auto booking (60 wagons' worth) gets NO partial offer — it is sized as 240 tons", async () => { + await closeBookingWindow(giantScheduleId); + await completeDocReview(giantScheduleId); + await pollBookingStatus(bg1, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + + // DEFECT 1 (see header): the split sizer reads the item count as tonnage, + // so a booking needing 60 wagons looks like it needs 4 and no offer opens. + const offers = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.booking_batch_offers + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bg1], + ); + expect(Number(offers[0].n), "no partial offer for the per-item giant").toBe(0); + + const row = await bookingRow(bg1); + expect(Number(row.cargo_total_weight_vgm), "cargoTotalWeightVgm holds ITEMS").toBe(240); + expect(row.wagons_required, "reserved without a wagon count").toBeNull(); + + // Nothing is allocated: the reservation cannot be honoured on a 54-wagon + // train, and no offer exists to shrink it. + const [alloc] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bg1], + ); + expect(Number(alloc.n), "no wagons allocated").toBe(0); + }, 900_000); + + it.skip("the 24-auto outstanding must be rebooked EXACTLY on the later train", async () => { + // Unreachable while defect 1 stands: no split ever applies, so there is no + // outstanding remainder and the contract still carries a live booking + // (rebooking answers 409 "already has an active booking"). Un-skip with the + // fix to sizeOffer. + }); + + it("the contract path DROPS a per-line hazardousQuantity for bulk", async () => { + const res = await bookBulkItems({ + contractId: contracts.get("BQ1")!, + cargoCode: "E2E_IMP_AUTO", + items: 10, + tons: 25, + scheduledDate: REMAINDER_DAY, + hazardousQuantity: 12, + }); + expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); + + const booking = await bookingFor(contracts.get("BQ1")!); + const [row] = await db<{ bulk_hazardous_quantity: string }>( + `SELECT bulk_hazardous_quantity FROM freight.bookings WHERE id = $1`, + [booking.id], + ); + // DEFECT 2 (see header). The scenario expects the 12 to be CLAMPED to the + // 10-item line and stored; the contract path stores nothing at all, so the + // hazmat surcharge never fires for a contract booking. The direct booking + // path does clamp (clampToCargo, bookings.service.ts) — it is the mapping + // in contract-booking.service.ts that is missing. + expect(Number(row.bulk_hazardous_quantity), "hazmat dropped, not clamped").toBe(0); + }); + + it("the contract path DROPS a per-line reeferQuantity for bulk", async () => { + const res = await bookBulkItems({ + contractId: contracts.get("BQ2")!, + cargoCode: "E2E_IMP_AUTO", + items: 8, + tons: 20, + scheduledDate: REMAINDER_DAY, + reeferQuantity: 3, + }); + expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); + + const booking = await bookingFor(contracts.get("BQ2")!); + const [row] = await db<{ bulk_reefer_quantity: string }>( + `SELECT bulk_reefer_quantity FROM freight.bookings WHERE id = $1`, + [booking.id], + ); + expect(Number(row.bulk_reefer_quantity), "reefer dropped").toBe(0); + }); +}); diff --git a/integration/src/bulk-export-fcfs-space.it.ts b/integration/src/bulk-export-fcfs-space.it.ts new file mode 100644 index 000000000..909291fd3 --- /dev/null +++ b/integration/src/bulk-export-fcfs-space.it.ts @@ -0,0 +1,182 @@ +/** + * BULK EXPORT — FCFS capacity truth. + * + * Day 1: three bookings (1 400 + 1 400 + 980 T = 54 wagons) accept first and + * hold the train BEFORE paying; three late 700 T exporters are rejected at + * submission by the whole-train space gate. The three pay → FULL. + * + * Day 2: whole-or-nothing — a 4 060 T giant (58 wagons) is rejected against + * an empty train (export never splits); rebooked at exactly 3 780 T it takes + * the whole consist alone; a 70 T afterthought bounces off FULL. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + EXP_DEST, + EXP_ORIGIN, + acceptExport, + allocatedWagons, + bookBulk, + bookBulkReady, + expectDayRefused, + bookingFor, + bookingRow, + createSchedule, + departureAt, + eatDayStr, + ensureExportRoute, + extendPayWindow, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(31); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const GIANT_DEPARTURE = departureAt(32); +const GIANT_DAY = eatDayStr(GIANT_DEPARTURE); +const STAMP = String(Date.now()); + +const FIRST = [ + { suffix: "YA", tons: 1400, wagons: 20 }, + { suffix: "YB", tons: 1400, wagons: 20 }, + { suffix: "YC", tons: 980, wagons: 14 }, +]; +const LATE = ["YL1", "YL2", "YL3"]; + +describe("bulk export FCFS: reservations hold capacity, whole-or-nothing gate", () => { + const booking = new Map(); + let contracts: Map; + let scheduleId: string; + let giantScheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureExportRoute(); + await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST); + await resetCorridorDay(GIANT_DEPARTURE, EXP_ORIGIN, EXP_DEST); + + contracts = await seedTenantContracts( + STAMP, + [...FIRST.map((b) => b.suffix), ...LATE, "YGBIG", "YG", "YS"].map((suffix) => ({ + suffix, + freight: "BULK" as const, + direction: "EXPORT" as const, + })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"], + originCode: EXP_ORIGIN, + destCode: EXP_DEST, + }) + ).id; + await forceWindowOpen(scheduleId, 60); + + for (const b of FIRST) { + booking.set( + b.suffix, + await bookBulkReady({ + contractId: contracts.get(b.suffix)!, + tons: b.tons, + scheduledDate: BOOKING_DAY, + mode: "export", + }), + ); + } + }, 1_800_000); + + afterAll(closeDb); + + it("holds 54 wagons on accept — before any payment", async () => { + for (const b of FIRST) { + const row = await bookingRow(booking.get(b.suffix)!); + expect(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], `${b.suffix} reserved`).toContain( + row.status, + ); + expect(row.train_schedule_id, `${b.suffix} pinned to the train`).toBe(scheduleId); + } + }); + + it("rejects three late exporters at submission — the space gate reports no room", async () => { + for (const suffix of LATE) { + const res = await expectDayRefused({ + contractId: contracts.get(suffix)!, + tons: 700, + scheduledDate: BOOKING_DAY, + }); + expect(res.status, `${suffix} rejected`).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i); + } + }); + + it("the three reserved pay — 54/54 allocated and the window flips FULL", async () => { + await extendPayWindow(scheduleId, FIRST.map((b) => booking.get(b.suffix)!)); + for (const b of FIRST) { + await payViaGateway(booking.get(b.suffix)!); + await pollAllocations(booking.get(b.suffix)!, b.wagons); + } + await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL"); + expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54); + }); + + it("whole-or-nothing: a 4 060 T giant is rejected against the empty train", async () => { + giantScheduleId = ( + await createSchedule({ + departure: GIANT_DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"], + originCode: EXP_ORIGIN, + destCode: EXP_DEST, + }) + ).id; + await forceWindowOpen(giantScheduleId, 60); + + // 58 wagons on a 54-wagon consist — export never splits. + // Its own tenant: a refused probe still leaves a live booking on the + // one-time contract, which would block the 3 780 T rebooking below. + const res = await expectDayRefused({ + contractId: contracts.get("YGBIG")!, + tons: 4060, + scheduledDate: GIANT_DAY, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i); + }); + + it("rebooked at exactly 3 780 T the giant takes the whole train alone", async () => { + const yg = await bookBulkReady({ + contractId: contracts.get("YG")!, + tons: 3780, + scheduledDate: GIANT_DAY, + mode: "export", + }); + await payViaGateway(yg); + await pollAllocations(yg, 54); + + await pollWindow( + giantScheduleId, + (s) => s.booking_window_status === "FULL", + "giant train FULL from one booking", + ); + expect((await bookingRow(yg)).train_schedule_id, "giant rides its train").toBe(giantScheduleId); + expect(await allocatedWagons(giantScheduleId), "54 wagons allocated").toBe(54); + }); + + it("a 70 T afterthought bounces off the FULL train", async () => { + const res = await expectDayRefused({ + contractId: contracts.get("YS")!, + tons: 70, + scheduledDate: GIANT_DAY, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i); + }); +}); diff --git a/integration/src/bulk-export-full-train.it.ts b/integration/src/bulk-export-full-train.it.ts new file mode 100644 index 000000000..75b3c444a --- /dev/null +++ b/integration/src/bulk-export-full-train.it.ts @@ -0,0 +1,169 @@ +/** + * BULK EXPORT — six wheat bookings fill the 54-wagon CW4 train on the reversed + * corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD → DJIB_PORT, inside + * the ONE FCFS export window, then the full life of the train to Djibouti Port + * and the export customs tail. + * + * Export is FCFS: the staff accept IS the reservation — there is no batch — and + * a pay deadline may never outlive the window close. Both are asserted here. + * + * 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T: + * XBF1 customs USD 560 T = 8 w · XBF2 customs ETB 420 T = 6 w + * XBF3 self ETB 420 T = 6 w · XBF4 self ETB 420 T = 6 w + * XBF5 customs USD 1 540 T = 22 w · XBF6 self USD 420 T = 6 w + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + EXP_DEST, + EXP_ORIGIN, + allocatedWagons, + bookBulkReady, + bookingRow, + createSchedule, + departureAt, + dispatchSchedule, + eatDayStr, + ensureExportRoute, + extendPayWindow, + expectMilestoneDone, + finalizeSchedule, + forceWindowOpen, + gatePassGranted, + invoiceForBooking, + milestoneCount, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + runCorridor, + scheduleRow, + seedTenantContracts, + closeT1, + completeMilestone, + uploadTransportDocument, +} from "./flows"; + +const DEPARTURE = departureAt(30); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +const BOOKINGS = [ + { suffix: "XBF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 }, + { suffix: "XBF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 }, + { suffix: "XBF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, + { suffix: "XBF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, + { suffix: "XBF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 }, + { suffix: "XBF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 }, +]; +const CUSTOMS = BOOKINGS.filter((b) => b.customs); +const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs); + +describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureExportRoute(); + await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST); + + const contracts = await seedTenantContracts( + STAMP, + BOOKINGS.map((b) => ({ + suffix: b.suffix, + currency: b.currency, + customs: b.customs, + freight: "BULK" as const, + direction: "EXPORT" as const, + })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"], + originCode: EXP_ORIGIN, + destCode: EXP_DEST, + }) + ).id; + await forceWindowOpen(scheduleId, 60); + + for (const b of BOOKINGS) { + booking.set( + b.suffix, + await bookBulkReady({ + contractId: contracts.get(b.suffix)!, + tons: b.tons, + scheduledDate: BOOKING_DAY, + mode: "export", + customs: b.customs, + }), + ); + } + }, 1_800_000); + + afterAll(closeDb); + + it("each accept reserved immediately, with a deadline clamped to the window close", async () => { + const schedule = await scheduleRow(scheduleId); + const closesAt = new Date(String(schedule.window_closes_at)).getTime(); + for (const b of BOOKINGS) { + const row = await bookingRow(booking.get(b.suffix)!); + expect(row.payment_deadline, `${b.suffix} pay deadline`).toBeTruthy(); + expect( + new Date(row.payment_deadline!).getTime(), + `${b.suffix} deadline never outlives the window close`, + ).toBeLessThanOrEqual(closesAt); + const invoice = await invoiceForBooking(booking.get(b.suffix)!); + expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency); + } + }); + + it("all six pay — 54/54 allocated, the export window flips FULL, staff finalize", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + for (const b of BOOKINGS) { + await payViaGateway(booking.get(b.suffix)!); + await pollAllocations(booking.get(b.suffix)!, b.wagons); + } + await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL"); + expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54); + + await finalizeSchedule(scheduleId); + await pollWindow(scheduleId, (s) => s.status === "SCHEDULED", "SCHEDULED"); + }); + + it("gate pass + transport documents, then the train runs the corridor to the port", async () => { + await gatePassGranted(scheduleId); + for (const b of CUSTOMS) { + const res = await uploadTransportDocument(booking.get(b.suffix)!); + expect(res.status, `${b.suffix} transport document`).toBeLessThanOrEqual(201); + } + + await dispatchSchedule(scheduleId); + await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED"); + await runCorridor(scheduleId); + for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20); + }); + + it("GL Djibouti closes the export tail on every customs booking", async () => { + for (const b of CUSTOMS) { + const id = booking.get(b.suffix)!; + await closeT1(id); + await completeMilestone(id, "OFFLOADED"); + await expectMilestoneDone(id, "T1_CLOSED"); + await expectMilestoneDone(id, "OFFLOADED"); + } + }); + + it("the self-clearing bookings arrived clean — no customs tail", async () => { + for (const b of SELF_CLEAR) { + const id = booking.get(b.suffix)!; + expect((await bookingRow(id)).status, `${b.suffix} final status`).toBe("ARRIVED"); + expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0); + } + }); +}); diff --git a/integration/src/bulk-export-matrix.it.ts b/integration/src/bulk-export-matrix.it.ts new file mode 100644 index 000000000..d4d03dd94 --- /dev/null +++ b/integration/src/bulk-export-matrix.it.ts @@ -0,0 +1,183 @@ +/** + * BULK EXPORT edge matrix (reversed corridor): + * a) a booking on a day with no open window is rejected + * b) mid-route boarding (DIRE_DAWA → port) shares the train with a KALITY + * through-booking + * c) directional FULL: the border edges are committed, so the window flips + * FULL while the home leg still has free wagons + * d) a dateless DOMESTIC ride-along boards the FULL train's free home leg — + * its pay window is clamped to the export close, it pays and links, and + * the window stays FULL + * e) a same-day sibling export train keeps its own independent window + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, db, gateway, poll } from "./client"; +import { + EXP_DEST, + EXP_ORIGIN, + acceptIntercityOnto, + acceptOperation, + allocatedWagons, + bookBulk, + bookBulkReady, + expectDayRefused, + bookingFor, + bookingRow, + clearIntercityBooking, + createSchedule, + departureAt, + eatDayStr, + ensureExportRoute, + extendPayWindow, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(35); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const NO_WINDOW_DAY = eatDayStr(departureAt(39)); // no schedule exists there +const STAMP = String(Date.now()); + +describe("bulk export matrix: sub-corridor, directional FULL, ride-along, own windows", () => { + let contracts: Map; + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureExportRoute(); + await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST); + + contracts = await seedTenantContracts(STAMP, [ + { suffix: "YM1", freight: "BULK", direction: "EXPORT" }, + { suffix: "YMNW", freight: "BULK", direction: "EXPORT" }, + { suffix: "YMSUB", freight: "BULK", direction: "EXPORT", originCode: "DIRE_DAWA", destCode: EXP_DEST }, + { suffix: "YMIC", freight: "BULK", direction: "DOMESTIC", originCode: EXP_ORIGIN, destCode: "MOJO" }, + ]); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"], + originCode: EXP_ORIGIN, + destCode: EXP_DEST, + }) + ).id; + await forceWindowOpen(scheduleId, 90); + }, 1_800_000); + + afterAll(closeDb); + + it("rejects a booking on a day with no open window", async () => { + const res = await expectDayRefused({ + contractId: contracts.get("YMNW")!, + tons: 140, + scheduledDate: NO_WINDOW_DAY, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i); + }); + + it("a through booking and a mid-route boarding commit the border edge", async () => { + const through = await bookBulkReady({ + contractId: contracts.get("YM1")!, + tons: 2800, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + const sub = await bookBulkReady({ + contractId: contracts.get("YMSUB")!, + tons: 980, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + + await extendPayWindow(scheduleId, [through, sub]); + await payViaGateway(through); + await pollAllocations(through, 40); + await payViaGateway(sub); + await pollAllocations(sub, 14); + + expect((await bookingRow(through)).train_schedule_id).toBe(scheduleId); + expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId); + + // 40 + 14 = 54 wagons committed on the border edge (…→ DJIB_PORT), yet the + // home leg (KALITY → DIRE_DAWA) still has 14 free — and the window stays + // OPEN on that free leg. NOTE: the older Cypress twin asserts FULL here; + // the live engine is leg-granular instead, which is why the ride-along + // below can still board. Assert the occupancy invariant, not the flag. + expect(await allocatedWagons(scheduleId), "border edge committed at 54").toBe(54); + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "window stays open on the free home leg", + ).toBe("OPEN"); + }, 1_800_000); + + it("a ride-along boards the train's free home leg — clamped, paid, linked", async () => { + const res = await bookBulk({ contractId: contracts.get("YMIC")!, tons: 140 }); + expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); + const ic = (await bookingFor(contracts.get("YMIC")!)).id; + await clearIntercityBooking(ic); + + await acceptIntercityOnto(scheduleId, ic); + await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + + const schedule = await scheduleRow(scheduleId); + expect( + new Date((await bookingRow(ic)).payment_deadline!).getTime(), + "ride-along deadline clamped to the export close", + ).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime()); + + await payViaGateway(ic); + // A paid ride-along is unpinned back to the pool; staff place it again. + await acceptIntercityOnto(scheduleId, ic); + await poll( + "ride-along linked to the export train", + `SELECT train_schedule_id FROM freight.bookings WHERE id = $1`, + [ic], + (row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId, + { attempts: 20 }, + ); + // The ride-along rides the home leg, so the border edge is untouched. + expect(await allocatedWagons(scheduleId), "border edge still 54").toBe(54); + }, 900_000); + + it("a same-day sibling export train keeps its own window", async () => { + const sibling = new Date(DEPARTURE.getTime() + 90 * 60_000); + await createSchedule({ + departure: sibling, + kind: "bulk", + locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"], + originCode: EXP_ORIGIN, + destCode: EXP_DEST, + }); + const anchor = await scheduleRow(scheduleId); + const [row] = await db<{ id: string; window_phase: string; window_closes_at: string }>( + `SELECT ts.id, ts.window_phase, ts.window_closes_at + FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1 + JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2 + WHERE ts.deleted_at IS NULL AND ts.id <> $3 + AND abs(extract(epoch FROM (ts.scheduled_departure_date - $4::timestamptz))) < 7200 + ORDER BY ts.created_at DESC LIMIT 1`, + [EXP_ORIGIN, EXP_DEST, scheduleId, DEPARTURE.toISOString()], + ); + expect(row, "sibling export schedule").toBeTruthy(); + expect(row.window_phase, "own fresh window").toBe("PRE_WINDOW"); + expect( + new Date(row.window_closes_at).getTime(), + "own close, anchored to its own departure", + ).not.toBe(new Date(String(anchor.window_closes_at)).getTime()); + }); +}); + +// `acceptOperation` is imported for symmetry with the import matrix; the export +// ride-along is accepted onto the train instead. +void acceptOperation; diff --git a/integration/src/bulk-export-pay-or-lose.it.ts b/integration/src/bulk-export-pay-or-lose.it.ts new file mode 100644 index 000000000..cb3ecfc22 --- /dev/null +++ b/integration/src/bulk-export-pay-or-lose.it.ts @@ -0,0 +1,196 @@ +/** + * BULK EXPORT — pay or lose the seat. + * + * Day 1: ZA + ZB reserve and pay 40 wagons. ZC reserves the last 14 (980 T) + * and never pays; while that hold lives a late booking is rejected for + * space. ZC expires → the late customer immediately books the freed 980 T + * and pays. + * + * Day 2: five reservations fill the train, only three pay. The window close + * passes → phase DONE, the two unpaid expire, and export never reopens + * (the cycle counter stays 1 — unlike import, which reopens). + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, db, gateway } from "./client"; +import { + EXP_DEST, + EXP_ORIGIN, + bookBulk, + bookBulkReady, + expectDayRefused, + bookingRow, + createSchedule, + departureAt, + eatDayStr, + ensureExportRoute, + extendPayWindow, + forceReservationExpiry, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(33); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const CLOSE_DEPARTURE = departureAt(34); +const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE); +const STAMP = String(Date.now()); + +const CLOSERS = [ + { suffix: "ZQA", tons: 840, wagons: 12, pays: true }, + { suffix: "ZQB", tons: 840, wagons: 12, pays: true }, + { suffix: "ZQC", tons: 840, wagons: 12, pays: true }, + { suffix: "ZQD", tons: 840, wagons: 12, pays: false }, + { suffix: "ZQE", tons: 420, wagons: 6, pays: false }, +]; + +describe("bulk export pay-or-lose: expiry frees space; close expires the unpaid", () => { + const booking = new Map(); + let contracts: Map; + let scheduleId: string; + let closeScheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureExportRoute(); + await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST); + await resetCorridorDay(CLOSE_DEPARTURE, EXP_ORIGIN, EXP_DEST); + + contracts = await seedTenantContracts( + STAMP, + ["ZA", "ZB", "ZC", "ZD", "ZDLATE", ...CLOSERS.map((c) => c.suffix)].map((suffix) => ({ + suffix, + freight: "BULK" as const, + direction: "EXPORT" as const, + })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"], + originCode: EXP_ORIGIN, + destCode: EXP_DEST, + }) + ).id; + await forceWindowOpen(scheduleId, 60); + }, 1_800_000); + + afterAll(closeDb); + + it("ZA and ZB reserve and pay 40 wagons; ZC holds the last 14 unpaid", async () => { + for (const [suffix, wagons] of [ + ["ZA", 20], + ["ZB", 20], + ] as const) { + const id = await bookBulkReady({ + contractId: contracts.get(suffix)!, + tons: 1400, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + booking.set(suffix, id); + await payViaGateway(id); + await pollAllocations(id, wagons); + } + + const zc = await bookBulkReady({ + contractId: contracts.get("ZC")!, + tons: 980, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + booking.set("ZC", zc); + const schedule = await scheduleRow(scheduleId); + expect( + new Date((await bookingRow(zc)).payment_deadline!).getTime(), + "ZC deadline clamped to the window close", + ).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime()); + }, 900_000); + + it("a late exporter is rejected while ZC's unpaid reservation holds the space", async () => { + const res = await expectDayRefused({ + contractId: contracts.get("ZDLATE")!, + tons: 980, + scheduledDate: BOOKING_DAY, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i); + }); + + it("ZC misses its window — ZD immediately books the freed 980 T and pays", async () => { + await forceReservationExpiry(booking.get("ZC")!); + const zd = await bookBulkReady({ + contractId: contracts.get("ZD")!, + tons: 980, + scheduledDate: BOOKING_DAY, + mode: "export", + }); + booking.set("ZD", zd); + await payViaGateway(zd); + await pollAllocations(zd, 14); + expect((await bookingRow(zd)).train_schedule_id, "ZD took ZC's seat").toBe(scheduleId); + }, 900_000); + + it("window-close day: five reservations, three payments", async () => { + closeScheduleId = ( + await createSchedule({ + departure: CLOSE_DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"], + originCode: EXP_ORIGIN, + destCode: EXP_DEST, + }) + ).id; + await forceWindowOpen(closeScheduleId, 60); + + for (const c of CLOSERS) { + booking.set( + c.suffix, + await bookBulkReady({ + contractId: contracts.get(c.suffix)!, + tons: c.tons, + scheduledDate: CLOSE_DAY, + mode: "export", + }), + ); + } + await extendPayWindow( + closeScheduleId, + CLOSERS.filter((x) => x.pays).map((c) => booking.get(c.suffix)!), + ); + for (const c of CLOSERS.filter((x) => x.pays)) { + await payViaGateway(booking.get(c.suffix)!); + await pollAllocations(booking.get(c.suffix)!, c.wagons); + } + }, 1_800_000); + + it("the window CLOSES — phase DONE, the unpaid expire, and export never reopens", async () => { + await db( + `UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second' + WHERE id = $1 AND window_phase = 'OPEN'`, + [closeScheduleId], + ); + await pollWindow( + closeScheduleId, + (s) => s.window_phase === "DONE" && Number(s.booking_cycle_no) === 1, + "DONE, no reopen", + ); + + // A forced close needs the matching deadline clamp on the unpaid pair. + for (const c of CLOSERS.filter((x) => !x.pays)) { + await forceReservationExpiry(booking.get(c.suffix)!); + await pollBookingStatus(booking.get(c.suffix)!, "EXPIRED", 40); + } + for (const c of CLOSERS.filter((x) => x.pays)) { + expect((await bookingRow(booking.get(c.suffix)!)).status, `${c.suffix} rides`).toBe("PAID"); + } + }, 900_000); +}); diff --git a/integration/src/bulk-import-full-train.it.ts b/integration/src/bulk-import-full-train.it.ts new file mode 100644 index 000000000..f7ba4a5c0 --- /dev/null +++ b/integration/src/bulk-import-full-train.it.ts @@ -0,0 +1,185 @@ +/** + * BULK IMPORT — six wheat bookings fill the 54-wagon CW4 train on the long + * corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in + * the FIRST window, then the whole life of the train: payment through the real + * gateway, allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint + * movement, arrival, and the post-arrival customs tail. + * + * 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T: + * BF1 customs + USD 560 T = 8 w + * BF2 customs + ETB 420 T = 6 w + * BF3 self + ETB 420 T = 6 w + * BF4 self + ETB 420 T = 6 w + * BF5 customs + USD 1 540 T = 22 w (the ≥22-wagon giant) + * BF6 self + USD 420 T = 6 w + * + * Difference from the Cypress twin: every payment goes through the payment + * microservice and a signed gateway callback, not the staff mark-paid shortcut. + * Steps are sequential and not idempotent — the file runs as one journey. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, db, gateway } from "./client"; +import { + allocatedWagons, + bookBulkReady, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + dispatchSchedule, + eatDayStr, + endPaymentPhase, + extendPayWindow, + ensureCorridorRoute, + expectMilestoneDone, + forceWindowOpen, + gatePassGranted, + invoiceForBooking, + milestoneCount, + payViaGateway, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + runCorridor, + runImportCustomsTail, + scheduleRow, + seedTenantContracts, + uploadT1, +} from "./flows"; + +const DEPARTURE = departureAt(20); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +const BOOKINGS = [ + { suffix: "BF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 }, + { suffix: "BF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 }, + { suffix: "BF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, + { suffix: "BF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, + { suffix: "BF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 }, + { suffix: "BF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 }, +]; +const CUSTOMS = BOOKINGS.filter((b) => b.customs); +const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs); + +describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + BOOKINGS.map((b) => ({ + suffix: b.suffix, + currency: b.currency, + customs: b.customs, + freight: "BULK" as const, + })), + ); + + const schedule = await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"], + }); + scheduleId = schedule.id; + // The cycle counter turns over when the window opens, not at creation. + await forceWindowOpen(scheduleId, 45); + expect((await scheduleRow(scheduleId)).booking_cycle_no, "FIRST window cycle").toBe(1); + + for (const b of BOOKINGS) { + booking.set( + b.suffix, + await bookBulkReady({ + contractId: contracts.get(b.suffix)!, + tons: b.tons, + scheduledDate: BOOKING_DAY, + customs: b.customs, + }), + ); + } + }, 1_800_000); + + afterAll(closeDb); + + it("reserves all six with invoices in their contract currency — they fit exactly", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const b of BOOKINGS) { + const row = await pollBookingStatus(booking.get(b.suffix)!, [ + "SELECTED_FOR_BATCH", + "AWAITING_PAYMENT", + ]); + expect(row.payment_deadline ?? "pending", `${b.suffix} pay deadline`).toBeTruthy(); + const invoice = await invoiceForBooking(booking.get(b.suffix)!); + expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency); + } + }); + + it("all six pay through the gateway — 54/54 wagons, window FULL, schedule finalized", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + for (const b of BOOKINGS) { + await payViaGateway(booking.get(b.suffix)!); + } + await endPaymentPhase(scheduleId); + await pollWindow( + scheduleId, + (s) => + s.booking_window_status === "FULL" && + s.window_phase === "DONE" && + s.status === "SCHEDULED", + "FULL + DONE + finalized", + ); + expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54); + }); + + it("GL Djibouti grants the gate pass and uploads T1 for the customs bookings", async () => { + await gatePassGranted(scheduleId); + for (const b of CUSTOMS) { + const res = await uploadT1(booking.get(b.suffix)!); + expect(res.status, `${b.suffix} T1 upload`).toBeLessThanOrEqual(201); + } + }); + + it("the train dispatches and runs the corridor checkpoint by checkpoint", async () => { + await dispatchSchedule(scheduleId); + await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED"); + for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "IN_TRANSIT", 15); + + await runCorridor(scheduleId); + for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20); + + const [row] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`, + [scheduleId], + ); + expect(Number(row.n), "wagon movement ledger rows").toBeGreaterThanOrEqual(54); + }); + + it("GL runs the customs tail on every customs booking", async () => { + for (const b of CUSTOMS) { + const id = booking.get(b.suffix)!; + await runImportCustomsTail(id); + await expectMilestoneDone(id, "T1_CLOSED"); + await expectMilestoneDone(id, "RISK_ASSIGNED"); + await expectMilestoneDone(id, "IMPORT_RELEASE_GRANTED"); + await expectMilestoneDone(id, "IMPORT_PROCESS_COMPLETED"); + } + }); + + it("the self-clearing bookings arrived clean — no customs tail", async () => { + for (const b of SELF_CLEAR) { + const id = booking.get(b.suffix)!; + const row = await pollBookingStatus(id, "ARRIVED", 5); + expect(row.status, `${b.suffix} final status`).toBe("ARRIVED"); + expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0); + } + }); +}); diff --git a/integration/src/bulk-import-matrix.it.ts b/integration/src/bulk-import-matrix.it.ts new file mode 100644 index 000000000..c1ed7d015 --- /dev/null +++ b/integration/src/bulk-import-matrix.it.ts @@ -0,0 +1,224 @@ +/** + * BULK IMPORT edge matrix: + * a) a wheat booking on a day with no open window is rejected at submission + * b) a sub-corridor booking (NAGAD → MOJO) rides the through-train next to a + * DJIB_PORT → KALITY booking — the batch is corridor-aware + * c) bulk intercity ride-along (MOJO → KALITY, DOMESTIC, dateless): staff + * assign it onto the import train's free leg, the pay window opens, it + * pays and links + * d) whole-train giant: 4 000 T (58 wagons' worth) alone on a 54-wagon train + * → partial offer of the FULL consist (3 780 T); the gateway settlement + * applies the split and the train is FULL from ONE booking; the 220 T + * outstanding must be rebooked EXACTLY on a later train + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, db, gateway, poll } from "./client"; +import { + acceptIntercityOnto, + acceptOperation, + bookBulk, + bookBulkReady, + expectDayRefused, + bookingFor, + bookingRow, + clearIntercityBooking, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + extendPayWindow, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollPartialOffer, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(25); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const GIANT_DEPARTURE = departureAt(26); +const GIANT_DAY = eatDayStr(GIANT_DEPARTURE); +const REMAINDER_DEPARTURE = departureAt(27); +const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE); +const NO_WINDOW_DAY = eatDayStr(departureAt(29)); // no schedule exists there +const STAMP = String(Date.now()); + +describe("bulk import matrix: gates, sub-corridor, ride-along, whole-train giant", () => { + let contracts: Map; + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + await resetCorridorDay(GIANT_DEPARTURE); + await resetCorridorDay(REMAINDER_DEPARTURE); + + contracts = await seedTenantContracts(STAMP, [ + { suffix: "BM1", freight: "BULK" }, + { suffix: "BMNW", freight: "BULK" }, + { suffix: "BMSUB", freight: "BULK", originCode: "NAGAD", destCode: "MOJO" }, + { suffix: "BMIC", freight: "BULK", direction: "DOMESTIC", originCode: "MOJO", destCode: "KALITY" }, + { suffix: "BMG", freight: "BULK" }, + ]); + + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"], + }) + ).id; + await forceWindowOpen(scheduleId, 60); + }, 1_800_000); + + afterAll(closeDb); + + it("refuses a shipment day with no open window", async () => { + const res = await expectDayRefused({ + contractId: contracts.get("BMNW")!, + tons: 140, + scheduledDate: NO_WINDOW_DAY, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + // Wording differs by gate: the day probe answers "No departures available + // on the selected day", the route probe "the import booking window …". + expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i); + }); + + it("a through booking and a NAGAD→MOJO sub-corridor booking share the train", async () => { + const bm1 = await bookBulkReady({ + contractId: contracts.get("BM1")!, + tons: 1400, + scheduledDate: BOOKING_DAY, + }); + const sub = await bookBulkReady({ + contractId: contracts.get("BMSUB")!, + tons: 700, + scheduledDate: BOOKING_DAY, + }); + + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const id of [bm1, sub]) { + await pollBookingStatus(id, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + + await extendPayWindow(scheduleId, [bm1, sub]); + await payViaGateway(bm1); + await pollAllocations(bm1, 20); + await payViaGateway(sub); + await pollAllocations(sub, 10); + + expect((await bookingRow(bm1)).train_schedule_id).toBe(scheduleId); + expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId); + }); + + it("a dateless DOMESTIC ride-along boards the import train's free leg", async () => { + // 140 T = 2 wagons MOJO → KALITY, no shipment day of its own. + const res = await bookBulk({ contractId: contracts.get("BMIC")!, tons: 140 }); + expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); + const ic = (await bookingFor(contracts.get("BMIC")!)).id; + await clearIntercityBooking(ic); + + await acceptIntercityOnto(scheduleId, ic); + await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + expect((await bookingRow(ic)).payment_deadline, "ride-along pay window opened").toBeTruthy(); + + await payViaGateway(ic); + // Settlement UNPINS a paid ride-along back to the ride-along pool — staff + // then place it on the train they want (acceptIntercity's `paid` branch, + // booking-batch.service.ts). The Cypress twin never sees this: its staff + // mark-paid shortcut leaves the reservation pinned. + expect( + (await bookingRow(ic)).train_schedule_id, + "paid ride-along returns to the pool", + ).toBeNull(); + + await acceptIntercityOnto(scheduleId, ic); + await poll( + "ride-along linked to the import train", + `SELECT train_schedule_id FROM freight.bookings WHERE id = $1`, + [ic], + (row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId, + { attempts: 20 }, + ); + const [link] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`, + [ic, scheduleId], + ); + expect(Number(link.n), "link row").toBe(1); + }); + + it("a 4 000 T giant alone gets a FULL-consist partial offer and fills the train", async () => { + const giantSchedule = await createSchedule({ + departure: GIANT_DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"], + }); + await forceWindowOpen(giantSchedule.id, 45); + + const bmg = await bookBulkReady({ + contractId: contracts.get("BMG")!, + tons: 4000, + scheduledDate: GIANT_DAY, + }); + await closeBookingWindow(giantSchedule.id); + await completeDocReview(giantSchedule.id); + await pollBookingStatus(bmg, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + + const offer = await pollPartialOffer(bmg); + expect(Number(offer.offered_wagons), "offered the whole consist").toBe(54); + + await payViaGateway(bmg); + await pollAllocations(bmg, 54); + expect((await bookingRow(bmg)).is_split, "BMG is split").toBe(true); + + await endPaymentPhase(giantSchedule.id); + await pollWindow( + giantSchedule.id, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL from one booking", + ); + }); + + it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", async () => { + const remainder = await createSchedule({ + departure: REMAINDER_DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"], + }); + await forceWindowOpen(remainder.id, 45); + + const wrong = await bookBulk({ + contractId: contracts.get("BMG")!, + tons: 100, + scheduledDate: REMAINDER_DAY, + }); + expect(wrong.status).toBeGreaterThanOrEqual(400); + expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i); + + const exact = await bookBulk({ + contractId: contracts.get("BMG")!, + tons: 220, + scheduledDate: REMAINDER_DAY, + }); + expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201); + await acceptOperationIfPossible(contracts.get("BMG")!); + }); +}); + +/** The remainder booking only needs to exist; walking its gate is out of scope. */ +async function acceptOperationIfPossible(contractId: string) { + const booking = await bookingFor(contractId); + if (booking.status === "OPERATION_REQUEST_PENDING") await acceptOperation(booking.id); +} diff --git a/integration/src/bulk-import-split-promote.it.ts b/integration/src/bulk-import-split-promote.it.ts new file mode 100644 index 000000000..7f848ffe7 --- /dev/null +++ b/integration/src/bulk-import-split-promote.it.ts @@ -0,0 +1,195 @@ +/** + * BULK IMPORT — split offer, exact-remainder rebooking, pay-window expiry and + * priority-ordered waiting-list promotion on one 54-wagon CW4 train. Bulk + * splits are FULL-WAGONS-ONLY at the base 70 T cap. + * + * reserved (priority order): BSA 1 400 T = 20 w, BSB 980 T = 14 w, + * BSD 840 T = 12 w → 46 w. BSC 1 680 T = 24 w does NOT fit whole → PARTIAL + * offer of the remaining 8 wagons = 560 T. BSC settles through the payment + * service → the split applies (is_split + pre-split snapshot); the + * outstanding 1 120 T must later be rebooked EXACTLY. + * BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (6 w) and + * BS2 (6 w) in priority order; BS3 (20 w) never fits and expires. + * + * Final consist: 20 + 14 + 8 + 6 + 6 = 54/54. + * + * The split is the reason this file pays through the gateway rather than the + * staff shortcut: applying a pending partial offer hangs off + * `booking.invoice.paid`, which only a real settlement emits. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + allocatedWagons, + bookBulk, + bookBulkReady, + bookingRow, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + extendPayWindow, + ensureCorridorRoute, + forceReservationExpiry, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollPartialOffer, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, + setPriority, +} from "./flows"; + +const DEPARTURE = departureAt(23); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const REMAINDER_DEPARTURE = departureAt(24); +const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE); +const STAMP = String(Date.now()); + +const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const; +const TONS: Record = { + BSA: 1400, + BSB: 980, + BSD: 840, + BSC: 1680, + BS1: 420, + BS2: 420, + BS3: 1400, +}; + +describe("bulk import: split offer, remainder rebooking, expiry + promotion", () => { + const booking = new Map(); + let contracts: Map; + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + await resetCorridorDay(REMAINDER_DEPARTURE); + + contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "BULK" as const })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"], + }) + ).id; + await forceWindowOpen(scheduleId, 45); + + for (const [i, suffix] of ORDER.entries()) { + const id = await bookBulkReady({ + contractId: contracts.get(suffix)!, + tons: TONS[suffix], + scheduledDate: BOOKING_DAY, + }); + booking.set(suffix, id); + await setPriority(id, i + 1); + } + }, 1_800_000); + + afterAll(closeDb); + + it("reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const suffix of ["BSA", "BSB", "BSD", "BSC"]) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + const offer = await pollPartialOffer(booking.get("BSC")!); + expect(Number(offer.offered_wagons), "BSC offered the remaining 8 wagons").toBe(8); + + for (const suffix of ["BS1", "BS2", "BS3"]) { + expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} waiting`).toBe( + "FULLY_EXECUTED", + ); + } + }); + + it("BSA and BSB pay; BSC settles through the gateway — the split applies", async () => { + // Three sequential gateway settlements outlast the ~60s hold; BSD is left + // alone because the next test is about its expiry. + await extendPayWindow(scheduleId, [booking.get("BSA")!, booking.get("BSB")!, booking.get("BSC")!]); + await payViaGateway(booking.get("BSA")!); + await pollAllocations(booking.get("BSA")!, 20); + await payViaGateway(booking.get("BSB")!); + await pollAllocations(booking.get("BSB")!, 14); + + await payViaGateway(booking.get("BSC")!); + await pollAllocations(booking.get("BSC")!, 8); + const bsc = await bookingRow(booking.get("BSC")!); + expect(bsc.is_split, "BSC is split").toBe(true); + expect(bsc.pre_split_quantities, "pre-split snapshot kept").toBeTruthy(); + }); + + it("BSD misses its pay window — the freed wagons promote BS1 + BS2 in priority order", async () => { + await forceReservationExpiry(booking.get("BSD")!); + for (const suffix of ["BS1", "BS2"]) { + const row = await pollBookingStatus(booking.get(suffix)!, [ + "SELECTED_FOR_BATCH", + "AWAITING_PAYMENT", + ]); + expect(row.status, `${suffix} promoted`).not.toBe("FULLY_EXECUTED"); + expect( + (await bookingRow(booking.get(suffix)!)).payment_deadline, + `${suffix} got a pay window`, + ).toBeTruthy(); + } + expect((await bookingRow(booking.get("BS3")!)).status, "BS3 still has no seat").toBe( + "FULLY_EXECUTED", + ); + }); + + it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", async () => { + await extendPayWindow(scheduleId, [booking.get("BS1")!, booking.get("BS2")!]); + await payViaGateway(booking.get("BS1")!); + await pollAllocations(booking.get("BS1")!, 6); + await payViaGateway(booking.get("BS2")!); + await pollAllocations(booking.get("BS2")!, 6); + + await endPaymentPhase(scheduleId); + await pollWindow( + scheduleId, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL + DONE", + ); + expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54); + await pollBookingStatus(booking.get("BS3")!, "EXPIRED", 40); + }); + + it("the split customer must rebook EXACTLY the 1 120 T remainder", async () => { + const remainderSchedule = await createSchedule({ + departure: REMAINDER_DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"], + }); + await forceWindowOpen(remainderSchedule.id, 45); + + // 1 680 booked − 560 shipped by the split = 1 120 T outstanding. + const wrong = await bookBulk({ + contractId: contracts.get("BSC")!, + tons: 560, + scheduledDate: REMAINDER_DAY, + }); + expect(wrong.status, "partial remainder rejected").toBeGreaterThanOrEqual(400); + expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i); + + const exact = await bookBulk({ + contractId: contracts.get("BSC")!, + tons: 1120, + scheduledDate: REMAINDER_DAY, + }); + expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201); + }); +}); diff --git a/integration/src/bulk-import-waiting-expiry.it.ts b/integration/src/bulk-import-waiting-expiry.it.ts new file mode 100644 index 000000000..7a74f6162 --- /dev/null +++ b/integration/src/bulk-import-waiting-expiry.it.ts @@ -0,0 +1,125 @@ +/** + * BULK IMPORT — the CW4 train fills from THREE wheat bookings; three more sit + * in the waiting pool of the same window. The selected trio pays through the + * gateway and allocates; when the cycle concludes FULL the waiting three + * expire with the day — they never ride and never pay. + * + * 70 T per CW4 wagon, 54-wagon consist: + * selected: BWA 1 400 T = 20 w, BWB 1 400 T = 20 w, BWC 980 T = 14 w → Σ 54 + * waiting: BW1 / BW2 / BW3 700 T = 10 w each + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + allocatedWagons, + bookBulkReady, + bookingRow, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + extendPayWindow, + ensureCorridorRoute, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, + setPriority, +} from "./flows"; + +const DEPARTURE = departureAt(21); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +const SELECTED = [ + { suffix: "BWA", tons: 1400, wagons: 20 }, + { suffix: "BWB", tons: 1400, wagons: 20 }, + { suffix: "BWC", tons: 980, wagons: 14 }, +]; +const WAITING = [ + { suffix: "BW1", tons: 700, wagons: 10 }, + { suffix: "BW2", tons: 700, wagons: 10 }, + { suffix: "BW3", tons: 700, wagons: 10 }, +]; +const ALL = [...SELECTED, ...WAITING]; + +describe("bulk import: three bookings fill the train, three wait and expire", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ALL.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"], + }) + ).id; + await forceWindowOpen(scheduleId, 45); + + for (const [i, b] of ALL.entries()) { + const id = await bookBulkReady({ + contractId: contracts.get(b.suffix)!, + tons: b.tons, + scheduledDate: BOOKING_DAY, + }); + booking.set(b.suffix, id); + // Priority order = the order above: the exact-fill trio picks first. + await setPriority(id, i + 1); + } + }, 1_800_000); + + afterAll(closeDb); + + it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const b of SELECTED) { + await pollBookingStatus(booking.get(b.suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + for (const b of WAITING) { + const row = await bookingRow(booking.get(b.suffix)!); + expect(row.status, `${b.suffix} still waiting`).toBe("FULLY_EXECUTED"); + expect(row.payment_deadline, `${b.suffix} has no pay deadline`).toBeNull(); + } + }); + + it("the three selected pay through the gateway and allocate — 54/54", async () => { + await extendPayWindow(scheduleId, SELECTED.map((b) => booking.get(b.suffix)!)); + for (const b of SELECTED) { + await payViaGateway(booking.get(b.suffix)!); + await pollAllocations(booking.get(b.suffix)!, b.wagons); + } + expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54); + }); + + it("the cycle concludes FULL — the three waiting bookings expire with the day", async () => { + await endPaymentPhase(scheduleId); + await pollWindow( + scheduleId, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL + DONE", + ); + for (const b of WAITING) await pollBookingStatus(booking.get(b.suffix)!, "EXPIRED", 40); + for (const b of SELECTED) { + const row = await bookingRow(booking.get(b.suffix)!); + expect(row.status, `${b.suffix} stays PAID`).toBe("PAID"); + } + }); +}); diff --git a/integration/src/bulk-import-window-reopen.it.ts b/integration/src/bulk-import-window-reopen.it.ts new file mode 100644 index 000000000..363012987 --- /dev/null +++ b/integration/src/bulk-import-window-reopen.it.ts @@ -0,0 +1,127 @@ +/** + * BULK IMPORT — nobody pays in the first cycle: both reserved wheat bookings + * expire, the cycle concludes NOT-full, and the window REOPENS for a second + * cycle on the same train. A fresh 700 T booking arrives in cycle 2, pays + * through the gateway, and allocates. Import days reopen; they don't die. + * + * The expiry itself is only possible because the payment service is real here: + * before expiring an unpaid hold the engine asks the gateway whether a late + * payment landed, and defers forever on an unverifiable answer. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + bookBulkReady, + bookingRow, + closeBookingWindow, + completeDocReview, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + forceReservationExpiry, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(22); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); +const SUFFIXES = ["BRA", "BRB", "BRC"] as const; + +describe("bulk import: dead first cycle — expire all, reopen, book again", () => { + const booking = new Map(); + let contracts: Map; + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + contracts = await seedTenantContracts( + STAMP, + SUFFIXES.map((suffix) => ({ suffix, freight: "BULK" as const })), + ); + scheduleId = ( + await createSchedule({ + departure: DEPARTURE, + kind: "bulk", + locoPair: ["LOCO-IMP-23", "LOCO-IMP-24"], + }) + ).id; + await forceWindowOpen(scheduleId, 45); + expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 1").toBe(1); + + for (const suffix of ["BRA", "BRB"] as const) { + booking.set( + suffix, + await bookBulkReady({ + contractId: contracts.get(suffix)!, + tons: 1400, + scheduledDate: BOOKING_DAY, + }), + ); + } + }, 1_800_000); + + afterAll(closeDb); + + it("two customers are reserved in cycle 1", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ["BRA", "BRB"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + }); + + it("nobody pays — both reservations expire and the cycle concludes not-full", async () => { + for (const suffix of ["BRA", "BRB"] as const) { + await forceReservationExpiry(booking.get(suffix)!); + } + await endPaymentPhase(scheduleId); + // When the reopen instant lands inside office hours the 10s tick chains + // PRE_WINDOW straight into OPEN, so PRE_WINDOW is not a reliably observable + // resting state — assert only that the cycle left PAYMENT without DONE. + await pollWindow( + scheduleId, + (s) => s.window_phase !== "PAYMENT" && s.window_phase !== "DONE", + "concluded not-full", + ); + }); + + it("the second window opens (cycle 2) and a fresh 700 T booking pays and allocates", async () => { + await forceWindowOpen(scheduleId, 45); + expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 2").toBe(2); + + const brc = await bookBulkReady({ + contractId: contracts.get("BRC")!, + tons: 700, + scheduledDate: BOOKING_DAY, + }); + booking.set("BRC", brc); + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + await pollBookingStatus(brc, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + + await payViaGateway(brc); + await pollAllocations(brc, 10); + + for (const suffix of ["BRA", "BRB"] as const) { + const row = await bookingRow(booking.get(suffix)!); + expect(row.status, `${suffix} stays expired`).toBe("EXPIRED"); + } + expect((await bookingRow(brc)).train_schedule_id, "BRC rides the reopened train").toBe( + scheduleId, + ); + }); +}); diff --git a/integration/src/cbe-bill.it.ts b/integration/src/cbe-bill.it.ts new file mode 100644 index 000000000..974d00b07 --- /dev/null +++ b/integration/src/cbe-bill.it.ts @@ -0,0 +1,180 @@ +/** + * CBE Unified Bill — the INBOUND direction, and the only flow where the + * payment service calls freight rather than the other way round: + * + * customer picks CBE_BILL → freight → payment API mints a bill reference + * CBE POST /cbe/oauth/token → bearer token we issued + * CBE POST /cbe/query → payment API → freight /internal/payments/bill-query + * → payer name + live balance + * CBE POST /cbe/payment → intent settles → freight invoice PAID + * + * Both hops run real code on both sides; nothing is stubbed here at all. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import { PAYMENT_API, closeDb, db, gateway, poll } from "./client"; +import { + createImportSchedule, + currentInvoice, + departureAt, + ensureCorridorRoute, + releaseUnpaidHolds, + forceWindowOpen, + gatewayIntent, + invoiceForBooking, + payInvoice, + prepareBooking, + resetCorridorDay, + runBatch, + type ReadyBooking, +} from "./flows"; + +const DEPARTURE = departureAt(10); +const STAMP = String(Date.now()); +const API_NAME = "EDR_FREIGHT"; + +const txnId = (tag: string) => `IT-${tag}-${Date.now()}`; + +async function cbeToken(): Promise { + const res = await request(PAYMENT_API).post("/cbe/oauth/token").send({ + grant_type: "client_credentials", + client_id: "it-cbe-bill", + client_secret: "it-cbe-bill-secret", + scope: "Unified_Outgoing", + }); + const token = res.body?.access_token ?? res.body?.data?.access_token; + if (!token) throw new Error(`cbe token failed: ${res.status} ${JSON.stringify(res.body)}`); + return token; +} + +const cbe = (token: string, path: string, body: object) => + request(PAYMENT_API).post(path).set("Authorization", `Bearer ${token}`).send(body); + +describe("CBE Unified Bill (payment service as biller)", () => { + let booking: ReadyBooking; + let invoiceId: string; + let billId: string; + let token: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + const schedule = await createImportSchedule({ departure: DEPARTURE }); + await forceWindowOpen(schedule.id, 45); + booking = await prepareBooking({ + suffix: "BILL1", + departure: DEPARTURE, + runStamp: STAMP, + isoSeed: 300, + twenty: 2, + currency: "ETB", + }); + await runBatch(schedule.id); + invoiceId = (await invoiceForBooking(booking.bookingId)).id; + + const res = await payInvoice(invoiceId, { method: "CBE_BILL" }); + expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); + + const intent = await gatewayIntent(booking.bookingId); + const [row] = await db<{ bill_reference: string }>( + `SELECT bill_reference FROM edr_payment.payment_intent WHERE id = $1`, + [intent.id], + ); + billId = row.bill_reference; + token = await cbeToken(); + }, 600_000); + + afterAll(closeDb); + + it("mints a 12-digit bill reference the customer can quote at any CBE channel", () => { + expect(billId).toMatch(/^\d{12}$/); + }); + + it("refuses the query without a token we issued", async () => { + const res = await request(PAYMENT_API) + .post("/cbe/query") + .send({ Destination_Api_Name: API_NAME, End_To_End_Txn_Id: txnId("noauth"), Bill_Id: billId }); + expect(res.status).toBe(401); + }); + + it("answers the bill lookup from live freight data", async () => { + const invoice = await currentInvoice(invoiceId); + const res = await cbe(token, "/cbe/query", { + Destination_Api_Name: API_NAME, + End_To_End_Txn_Id: txnId("q1"), + Bill_Id: billId, + }); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("0"); + expect(res.body.Bill_Id).toBe(billId); + // The amount and payer come from freight's billQuery, not from a cached + // copy in the payment service. + expect(Number(res.body.Total_Amount)).toBeCloseTo(Number(invoice.balance_amount), 2); + expect(res.body.Full_Name).toBe("E2E Logistics PLC"); + expect(res.body.Payment_Reason).toMatch(/invoice/i); + }); + + it("reports an unknown bill as a business failure, not an error", async () => { + const res = await cbe(token, "/cbe/query", { + Destination_Api_Name: API_NAME, + End_To_End_Txn_Id: txnId("q404"), + Bill_Id: "000000000000", + }); + // Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200 + // as a channel fault and retries. + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("3"); + }); + + it("settles the freight invoice when CBE reports the debit", async () => { + const invoice = await currentInvoice(invoiceId); + const res = await cbe(token, "/cbe/payment", { + Destination_Api_Name: API_NAME, + End_To_End_Txn_Id: txnId("p1"), + Cbe_Txn_Ref: `CBE${Date.now()}`, + Timestamp: new Date().toISOString(), + Bill_Id: billId, + Amount: String(invoice.balance_amount), + Currency: "ETB", + Full_Name: "IT Payer", + Phone_No: "+251911000001", + }); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("0"); + + const paid = await poll<{ status: string }>( + "invoice PAID via CBE bill", + `SELECT status FROM freight.invoices WHERE id = $1`, + [invoiceId], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + expect(paid.status).toBe("PAID"); + }); + + it("rejects a second debit on the same bill", async () => { + const res = await cbe(token, "/cbe/payment", { + Destination_Api_Name: API_NAME, + End_To_End_Txn_Id: txnId("p2"), + Cbe_Txn_Ref: `CBE${Date.now()}`, + Timestamp: new Date().toISOString(), + Bill_Id: billId, + Amount: "1.00", + Currency: "ETB", + }); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("3"); + }); + + it("reports an already-paid bill on a later query", async () => { + const res = await cbe(token, "/cbe/query", { + Destination_Api_Name: API_NAME, + End_To_End_Txn_Id: txnId("q2"), + Bill_Id: billId, + }); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("3"); + }); +}); diff --git a/integration/src/client.ts b/integration/src/client.ts new file mode 100644 index 000000000..c9af9ec87 --- /dev/null +++ b/integration/src/client.ts @@ -0,0 +1,218 @@ +/** + * Plumbing for the integration suite: HTTP against the containerized freight + * and payment APIs, SQL against their shared throwaway Postgres, and the + * gateway mock's control plane. + * + * This is the Cypress-free port of e2e/freight/cypress/e2e/flows/import-utils.ts — + * same request sequences, same SQL, `pg.Pool` instead of `cy.task`. + */ +import request from "supertest"; +import { Pool, type QueryResultRow } from "pg"; + +export const API = process.env.IT_API_URL ?? "http://localhost:3111"; +export const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113"; +export const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600"; +export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token"; + +const DB_URL = + process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e"; + +// --------------------------------------------------------------------------- +// users (e2e/freight/cypress/fixtures/seed-users.sql + users.json) +// --------------------------------------------------------------------------- + +export const customerA = "user@gmail.com"; +export const customerB = "user2@gmail.com"; +export const opsStaff = "operation@edr.local"; +export const chief = "chief@edr.local"; +/** isSuperAdmin bypasses assertFreightPermission — used for the GL/clearance steps. */ +export const superAdmin = "superadmin@tria.com"; + +const STAFF_PASSWORD = "password@tria"; +const CUSTOMER_PASSWORD = "12345678"; + +// --------------------------------------------------------------------------- +// database +// --------------------------------------------------------------------------- + +const pool = new Pool({ connectionString: DB_URL, max: 12 }); + +export async function db>( + sql: string, + params: unknown[] = [], +): Promise { + const res = await pool.query(sql, params); + return res.rows; +} + +export async function closeDb(): Promise { + await pool.end(); +} + +/** Poll a query until `check` passes. Async settlement here is broker-driven. */ +export async function poll>( + label: string, + sql: string, + params: unknown[], + check: (row: T | undefined) => boolean, + { attempts = 40, intervalMs = 2000 } = {}, +): Promise { + let last: T | undefined; + for (let i = 0; i < attempts; i++) { + last = (await db(sql, params))[0]; + if (check(last)) return last as T; + await sleep(intervalMs); + } + throw new Error( + `timed out waiting for ${label} after ${attempts} attempts — last row: ${JSON.stringify(last)}`, + ); +} + +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// --------------------------------------------------------------------------- +// auth +// --------------------------------------------------------------------------- + +const tokens = new Map(); + +/** + * Bearer token for a seeded account. The login audience is not cosmetic: + * `user_type='individual'` accounts are rejected on the backoffice audience and + * vice versa (EDRFREIGHT-415), so it is derived from the address. + */ +export async function tokenFor(email: string): Promise { + const cached = tokens.get(email); + if (cached) return cached; + + const portal = email.endsWith("@gmail.com"); + const res = await request(API) + .post("/api/auth/login") + .set("x-client-app", portal ? "portal" : "backoffice") + .send({ email, password: portal ? CUSTOMER_PASSWORD : STAFF_PASSWORD }); + + // The login response is flattened by the app (no `.data` envelope) and 201s. + const token = res.body?.token ?? res.body?.data?.token; + if (!token) { + throw new Error(`login failed for ${email}: ${res.status} ${JSON.stringify(res.body)}`); + } + tokens.set(email, token); + return token; +} + +/** Login without caching, so audience-rejection can be asserted. */ +export function login(email: string, password: string, app: "portal" | "backoffice") { + return request(API).post("/api/auth/login").set("x-client-app", app).send({ email, password }); +} + +// --------------------------------------------------------------------------- +// freight API +// --------------------------------------------------------------------------- + +export type Method = "get" | "post" | "patch" | "delete"; + +/** Authenticated call to the freight API as `email`. Never throws on 4xx/5xx. */ +export async function api( + email: string, + method: Method, + path: string, + body?: unknown, +): Promise { + const token = await tokenFor(email); + const req = request(API)[method](path).set("Authorization", `Bearer ${token}`); + return method === "get" || method === "delete" ? req.send() : req.send(body ?? {}); +} + +/** Same, but fails loudly on a non-2xx — for arrange steps that must succeed. */ +export async function apiOk( + email: string, + method: Method, + path: string, + body?: unknown, +): Promise { + const res = await api(email, method, path, body); + if (res.status < 200 || res.status > 201) { + throw new Error( + `${method.toUpperCase()} ${path} as ${email} → ${res.status}: ${JSON.stringify(res.body)}`, + ); + } + return res; +} + +/** Multipart upload (clearance documents). supertest handles the encoding. */ +export async function upload( + email: string, + path: string, + filePath: string, + field = "files", + fields: Record = {}, +): Promise { + const token = await tokenFor(email); + const req = request(API).post(path).set("Authorization", `Bearer ${token}`); + for (const [k, v] of Object.entries(fields)) req.field(k, v); + return req.attach(field, filePath); +} + +// --------------------------------------------------------------------------- +// payment API (service-to-service surface) +// --------------------------------------------------------------------------- + +export function payment(method: Method, path: string, body?: unknown) { + const req = request(PAYMENT_API)[method](path).set("x-service-token", SERVICE_TOKEN); + return method === "get" || method === "delete" ? req.send() : req.send(body ?? {}); +} + +/** Payment-side rows. The payment service owns its own schema in the same DB. */ +export function paymentDb>( + sql: string, + params: unknown[] = [], +) { + return db(sql, params); +} + +export interface IntentRow extends QueryResultRow { + id: string; + status: string; + provider: string; + merchant_order_id: string; + amount_minor: string; + currency: string; + reference_id: string; + provider_txn_id: string | null; + expires_at: string | null; +} + +export function intentByMerchantOrderId(merchantOrderId: string) { + return db( + `SELECT * FROM edr_payment.payment_intent WHERE merchant_order_id = $1`, + [merchantOrderId], + ); +} + +// --------------------------------------------------------------------------- +// gateway mock control plane +// --------------------------------------------------------------------------- + +export const gateway = { + reset: () => request(GATEWAY).post("/__control/reset").send({}), + + /** Force a provider's next `times` calls (or all of them) into a mode. */ + mode: (provider: string, mode: "ok" | "fail" | "timeout" | "pending" | "paid", times?: number) => + request(GATEWAY).post(`/__control/provider/${provider}`).send({ mode, times }), + + /** Mark the order settled at the gateway WITHOUT a callback (polling path). */ + settle: (merchantOrderId: string) => + request(GATEWAY).post("/__control/settle").send({ merchantOrderId }), + + /** Fire a signed provider callback at the payment API. */ + webhook: (opts: { + merchantOrderId: string; + provider?: string; + status?: string; + eventId?: string; + transactionId?: string; + signature?: "bad"; + }) => request(GATEWAY).post("/__control/webhook").send(opts), + + calls: () => request(GATEWAY).get("/__control/calls").send(), +}; diff --git a/integration/src/concurrency.it.ts b/integration/src/concurrency.it.ts new file mode 100644 index 000000000..d59b1dff5 --- /dev/null +++ b/integration/src/concurrency.it.ts @@ -0,0 +1,215 @@ +/** + * Many users, at once. Each test drives one production race through the real + * HTTP surface and asserts the guard that is supposed to hold: + * + * - two settlements of one invoice → billing.markInvoiceAsPaid pessimistic lock + * - a replayed callback storm → webhook dedupe on externalEventId + * - two tenants, one wagon budget → reserveOnExport re-verify under lock (H8) + * - an invoice-number burst → pg_advisory_xact_lock in invoice-numbering + * - pay after the window closed → payInvoice dueAt gate + * + * These are the tests expected to find things. When one fails, read it as a + * finding, not as a flaky assertion. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + closeDb, + customerA, + customerB, + db, + gateway, + poll, + sleep, +} from "./client"; +import { + TIN_B, + createImportSchedule, + currentInvoice, + departureAt, + ensureCorridorRoute, + releaseUnpaidHolds, + forceWindowOpen, + gatewayIntent, + invoiceForBooking, + payInvoice, + prepareBooking, + resetCorridorDay, + runBatch, + type ReadyBooking, +} from "./flows"; + +const DEPARTURE = departureAt(8); +const STAMP = String(Date.now()); + +describe("concurrency and multi-tenant races", () => { + let scheduleId: string; + let a: ReadyBooking; + let b: ReadyBooking; + let invoiceA: string; + let invoiceB: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + const schedule = await createImportSchedule({ departure: DEPARTURE }); + scheduleId = schedule.id; + await forceWindowOpen(scheduleId, 45); + + // Two DIFFERENT tenants on the same train-day. + a = await prepareBooking({ + suffix: "CON-A", + departure: DEPARTURE, + runStamp: STAMP, + isoSeed: 200, + twenty: 2, + }); + b = await prepareBooking({ + suffix: "CON-B", + departure: DEPARTURE, + runStamp: STAMP, + isoSeed: 210, + twenty: 2, + tin: TIN_B, + as: customerB, + }); + await runBatch(scheduleId); + invoiceA = (await invoiceForBooking(a.bookingId)).id; + invoiceB = (await invoiceForBooking(b.bookingId)).id; + }, 900_000); + + afterAll(closeDb); + + it("settles once when two callbacks land simultaneously", async () => { + expect((await payInvoice(invoiceA, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201); + const intent = await gatewayIntent(a.bookingId); + + // Five concurrent deliveries of the SAME provider event. + const results = await Promise.all( + Array.from({ length: 5 }, () => + gateway.webhook({ + merchantOrderId: intent.merchant_order_id, + eventId: `RACE-${intent.merchant_order_id}`, + }), + ), + ); + expect(results.every((r) => r.body.delivered === 200)).toBe(true); + + await poll<{ status: string }>( + "invoice PAID under duplicate delivery", + `SELECT status FROM freight.invoices WHERE id = $1`, + [invoiceA], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + await sleep(3000); + + // Dedupe is at the webhook table: one row, one outbox event, one ledger entry. + const [{ n: events }] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event + WHERE merchant_order_id = $1`, + [intent.merchant_order_id], + ); + expect(Number(events)).toBe(1); + + const [{ n: outbox }] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM edr_payment.notification_outbox + WHERE intent_id = $1 AND event_type = 'payment.succeeded'`, + [intent.id], + ); + expect(Number(outbox)).toBe(1); + + const invoice = await currentInvoice(invoiceA); + expect((invoice.payments as unknown[]).length).toBe(1); + expect(Number(invoice.balance_amount)).toBe(0); + }); + + it("credits an invoice once even when two intents settle for it", async () => { + // The per-reference unique index was dropped (migration 1782200000000), so + // two intents on one booking are legal now. Both settling must still not + // double-credit the invoice. + const paid = await payInvoice(invoiceB, { method: "CBE_BIRR", as: customerB }); + expect(paid.status, JSON.stringify(paid.body)).toBeLessThanOrEqual(201); + const first = await gatewayIntent(b.bookingId); + + // A second initiate for the same reference, different provider. + const second = await payInvoice(invoiceB, { method: "TELEBIRR", as: customerB }); + expect(second.status).toBeLessThanOrEqual(201); + + await Promise.all([ + gateway.webhook({ merchantOrderId: first.merchant_order_id }), + gateway.webhook({ + merchantOrderId: first.merchant_order_id, + eventId: `SECOND-${first.merchant_order_id}`, + }), + ]); + + await poll<{ status: string }>( + "invoice PAID once", + `SELECT status FROM freight.invoices WHERE id = $1`, + [invoiceB], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + await sleep(4000); + + const invoice = await currentInvoice(invoiceB); + expect(Number(invoice.paid_amount)).toBeLessThanOrEqual(Number(invoice.total_amount)); + expect(Number(invoice.balance_amount)).toBe(0); + }); + + it("gives two tenants distinct, gapless invoice numbers under a burst", async () => { + const numbers = await db<{ invoice_number: string }>( + `SELECT invoice_number FROM freight.invoices + WHERE created_at > now() - interval '30 minutes' AND deleted_at IS NULL`, + ); + const seen = numbers.map((r) => r.invoice_number); + expect(new Set(seen).size).toBe(seen.length); + }); + + it("never over-reserves the train when both tenants push at once", async () => { + // The batch already ran for this day. Assert the invariant it must keep: + // reserved wagons never exceed the consist. + const [row] = await db<{ max_wagons: number; reserved: string }>( + `SELECT ts.max_wagons, + COALESCE(SUM(b.wagons_required), 0)::text AS reserved + FROM freight.train_schedules ts + LEFT JOIN freight.bookings b + ON b.train_schedule_id = ts.id AND b.deleted_at IS NULL + AND b.status NOT IN ('EXPIRED','CANCELLED','REJECTED') + WHERE ts.id = $1 + GROUP BY ts.max_wagons`, + [scheduleId], + ); + expect(Number(row.reserved)).toBeLessThanOrEqual(Number(row.max_wagons)); + }); + + it("rejects a fresh payment once the pay window has closed", async () => { + // A booking whose deadline has passed must not be able to START a payment + // (billing.payInvoice dueAt gate) — a payment begun BEFORE the deadline is + // still honoured later by the expire-time gateway reconcile, which is why + // the gate lives on initiation and not on settlement. + const departure = departureAt(9); + await releaseUnpaidHolds(); + await resetCorridorDay(departure); + const schedule = await createImportSchedule({ departure }); + await forceWindowOpen(schedule.id, 45); + const third = await prepareBooking({ + suffix: "CON-C", + departure, + runStamp: STAMP, + isoSeed: 220, + twenty: 2, + }); + await runBatch(schedule.id); + const invoice = await invoiceForBooking(third.bookingId); + + await db(`UPDATE freight.invoices SET due_at = now() - interval '1 minute' WHERE id = $1`, [ + invoice.id, + ]); + const res = await payInvoice(invoice.id, { method: "CBE_BIRR", as: customerA }); + expect(res.status).toBe(400); + expect(JSON.stringify(res.body)).toMatch(/payment window/i); + }, 600_000); +}); diff --git a/integration/src/flows.ts b/integration/src/flows.ts new file mode 100644 index 000000000..96867b0ec --- /dev/null +++ b/integration/src/flows.ts @@ -0,0 +1,1680 @@ +/** + * Freight business steps, ported from e2e/freight/cypress/e2e/flows/import-utils.ts. + * + * Same philosophy as that file: contracts are seeded FULLY_EXECUTED in SQL + * (the wizard is the Cypress suite's job), everything after that — bookings, + * clearance, staff review, window phases, batching, invoicing, payment — runs + * through the real API. + * + * The one deliberate difference: `settleViaGateway` is gone. Where the Cypress + * suite fakes an intent row and POSTs to freight's own internal webhook, this + * suite pays through the real payment microservice and lets the gateway mock + * call back. That seam is the whole point of the suite. + */ +import { join } from "node:path"; +import { + api, + apiOk, + customerA, + db, + gateway, + opsStaff, + poll, + superAdmin, + upload, +} from "./client"; + +export const CORRIDOR = [ + "DJIB_PORT", + "NAGAD", + "DIRE_DAWA", + "E2E_AWASH", + "MOJO", + "KALITY", +] as const; +export const ORIGIN = "DJIB_PORT"; +export const DEST = "KALITY"; +/** The export corridor runs the same six stops reversed (ET → DJ). */ +export const EXP_ORIGIN = DEST; +export const EXP_DEST = ORIGIN; + +/** Reused from the Cypress fixtures — a tiny PDF that satisfies the doc gate. */ +export const DOC_FIXTURE = join( + process.cwd(), + "..", + "e2e", + "freight", + "cypress", + "fixtures", + "docs", + "license.pdf", +); + +// --------------------------------------------------------------------------- +// time — departures pinned to 12:00 EAT so the EAT day key is unambiguous +// --------------------------------------------------------------------------- + +export function departureAt(dayOffset: number): Date { + const eatNow = new Date(Date.now() + 3 * 3_600_000); + return new Date( + Date.UTC(eatNow.getUTCFullYear(), eatNow.getUTCMonth(), eatNow.getUTCDate() + dayOffset, 9, 0, 0), + ); +} + +export const eatDayStr = (d: Date) => + new Date(d.getTime() + 3 * 3_600_000).toISOString().slice(0, 10); + +/** ISO 6346-shaped container number, unique per run+seed (checksum unchecked). */ +export function isoNumber(runStamp: string, seed: number): string { + return `MSCU${String((Number(runStamp.slice(-6)) * 100 + seed) % 10_000_000).padStart(7, "0")}`; +} + +// --------------------------------------------------------------------------- +// contracts +// --------------------------------------------------------------------------- + +export interface SeedContractOpts { + suffix: string; + reference: string; + currency?: "ETB" | "USD"; + direction?: "IMPORT" | "EXPORT" | "DOMESTIC"; + freight?: "CONTAINER" | "BULK"; + /** Path B: booked by Global Logistics on the customer's behalf, customs tail after arrival. */ + customs?: boolean; + originCode?: string; + destCode?: string; + /** TIN of the owning company. One tenant per booking — see {@link ensureTenant}. */ + tin?: string; + /** + * Service type to sell the contract under. Defaults to the oldest one (RAIL, + * `includes_customs = false`). `RAIL_CUSTOMS` is what makes the rule engine's + * CUSTOMS priority band apply — see seed-customs-service-type.sql. + */ + serviceTypeCode?: string; +} + +export const TIN_A = "0102030405"; // seed-company.sql +export const TIN_B = "0102030406"; // seed-company-b.sql (this suite) + +/** + * Seed a FULLY_EXECUTED one-time contract, container or bulk, self-clearing or + * customs (Path B — the pre-booking boundary milestone is stamped COMPLETED so + * the booking gate opens). + */ +export async function seedContract(opts: SeedContractOpts): Promise { + const direction = opts.direction ?? "IMPORT"; + const freight = opts.freight ?? "CONTAINER"; + const customs = opts.customs ?? false; + const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED"; + await db( + `WITH c AS ( + INSERT INTO freight.contracts + (reference, company_id, company_profile_id, contract_kind, + trade_direction, freight_type, service_type_id, payment_currency, + customs_clearing_enabled, clearance_status, status, + fully_executed_at, contract_valid_from, contract_valid_until, + contract_summary) + SELECT $1, comp.id, + (SELECT p.id FROM freight.company_profiles p + WHERE p.company_id = comp.id AND p.deleted_at IS NULL + ORDER BY CASE + WHEN $2::text = 'EXPORT' AND p.type = 'exporter' THEN 0 + WHEN $2::text <> 'EXPORT' AND p.type = 'importer' THEN 0 + ELSE 1 + END + LIMIT 1), + 'ONE_TIME', $2::text, $7::text, + (SELECT st.id FROM freight.service_types st + WHERE $10::text IS NULL OR st.code = $10::text + ORDER BY st.created_at LIMIT 1), + $3, $8, + CASE WHEN $8 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END, + 'FULLY_EXECUTED', + now(), now() - interval '1 day', now() + interval '60 days', + 'IT payment-integration fixture contract' + FROM freight.companies comp + WHERE comp.tin = $4 + RETURNING id + ), r AS ( + INSERT INTO freight.contract_routes + (contract_id, origin_yard_id, destination_yard_id, sort_order) + SELECT c.id, o.id, d.id, 0 FROM c + JOIN freight.yards o ON o.code = $5 + JOIN freight.yards d ON d.code = $6 + RETURNING id + ), scope_container AS ( + INSERT INTO freight.contract_cargo_scope + (contract_id, container_size, cargo_free_text) + SELECT c.id, v.size, 'IT corridor cargo' + FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size) + WHERE $7::text = 'CONTAINER' + ), scope_bulk AS ( + INSERT INTO freight.contract_cargo_scope + (contract_id, cargo_type_id, cargo_free_text) + SELECT c.id, ct.id, 'IT corridor wheat' + FROM c JOIN freight.cargo_types ct ON ct.code = 'E2E_IMP_WHEAT' + WHERE $7::text = 'BULK' + ) + -- Path B gate: a customs ONE_TIME booking needs the pre-booking boundary + -- milestone COMPLETED (IMPORT → DO_COLLECTED, EXPORT → EXPORT_RELEASED). + INSERT INTO freight.clearance_milestones + (contract_id, milestone_code, milestone_label, status, triggered_at, sort_order) + SELECT c.id, $9, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0 + FROM c WHERE $8`, + [ + opts.reference, + direction, + opts.currency ?? "ETB", + opts.tin ?? TIN_A, + opts.originCode ?? (direction === "EXPORT" ? DEST : ORIGIN), + opts.destCode ?? (direction === "EXPORT" ? ORIGIN : DEST), + freight, + customs, + boundary, + opts.serviceTypeCode ?? null, + ], + ); + const rows = await db<{ id: string }>( + `SELECT id FROM freight.contracts WHERE reference = $1 ORDER BY created_at DESC LIMIT 1`, + [opts.reference], + ); + if (!rows[0]) throw new Error(`contract ${opts.reference} was not seeded`); + return rows[0].id; +} + +// --------------------------------------------------------------------------- +// route + schedule +// --------------------------------------------------------------------------- + +export async function routeId(originCode = ORIGIN, destCode = DEST): Promise { + const rows = await db<{ id: string }>( + `SELECT r.id FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1 + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2 + WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`, + [originCode, destCode], + ); + return rows[0]?.id ?? null; +} + +/** Create the 6-stop corridor route through the API if it doesn't exist yet. */ +export async function ensureCorridorRoute(): Promise { + if (await routeId()) return; + const yards = await db<{ id: string; code: string }>( + `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`, + [[...CORRIDOR]], + ); + if (yards.length !== CORRIDOR.length) { + throw new Error(`corridor yards missing: got ${yards.length}/${CORRIDOR.length}`); + } + const byCode = new Map(yards.map((y) => [y.code, y.id])); + await apiOk(opsStaff, "post", "/api/routes", { + milestones: CORRIDOR.map((code) => ({ yardId: byCode.get(code) })), + }); +} + +/** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */ +export async function ensureExportRoute(): Promise { + if (await routeId(EXP_ORIGIN, EXP_DEST)) return; + const stops = [...CORRIDOR].reverse(); + const yards = await db<{ id: string; code: string }>( + `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`, + [stops], + ); + if (yards.length !== stops.length) throw new Error("corridor yards missing"); + const byCode = new Map(yards.map((y) => [y.code, y.id])); + await apiOk(opsStaff, "post", "/api/routes", { + milestones: stops.map((code) => ({ yardId: byCode.get(code) })), + }); +} + +export interface ScheduleRow { + id: string; + status: string; + window_phase: string; + booking_window_status: string; + booking_cycle_no: number; + max_wagons: number; + scheduled_departure_date: string; + [k: string]: unknown; +} + +const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_status, + ts.booking_cycle_no, ts.max_wagons, ts.scheduled_departure_date`; + +export async function findSchedule( + departure: Date, + originCode = ORIGIN, + destCode = DEST, + windowSeconds = 3600, +): Promise { + const rows = await db( + `SELECT ${SCHEDULE_COLS} + FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1 + JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2 + WHERE ts.deleted_at IS NULL + AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < $4 + ORDER BY ts.created_at DESC LIMIT 1`, + [originCode, destCode, departure.toISOString(), windowSeconds], + ); + return rows[0]; +} + +export const findExportSchedule = (departure: Date) => + findSchedule(departure, EXP_ORIGIN, EXP_DEST); + +/** + * Wipe whatever a previous file left on this departure day. Same intent as + * resetCorridorDay in import-utils.ts: a schedule must never be soft-deleted + * while bookings still point at it, and leftover allocations keep eating wagons. + */ +export async function resetCorridorDay( + departure: Date, + originCode = ORIGIN, + destCode = DEST, +): Promise { + await db( + `WITH stale AS ( + SELECT ts.id FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1 + JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2 + WHERE ts.deleted_at IS NULL + AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 43200 + ), unlink AS ( + UPDATE freight.bookings b + SET train_schedule_id = NULL, + status = CASE WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT') + THEN 'EXPIRED' ELSE b.status END, + scheduling_status = 'NOT_SCHEDULED', + -- and its shipment DAY, or rescueStrandedPaidForDay re-places it: + -- that sweep takes any unlinked booking with payment_status PAID and + -- a scheduled_date on the day, so a previous run's paid fixtures + -- climb straight back onto the fresh schedule (18 stowaway wagons + -- the file never booked). + scheduled_date = NULL + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%' + AND b.train_schedule_id IN (SELECT id FROM stale) + ), drop_links AS ( + UPDATE freight.train_schedule_bookings SET deleted_at = now() + WHERE train_schedule_id IN (SELECT id FROM stale) AND deleted_at IS NULL + ), free_wagons AS ( + UPDATE freight.wagon_booking_allocations wba SET deleted_at = now() + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL + AND ct.reference LIKE 'CTR-IT-%' + AND b.train_schedule_id IN (SELECT id FROM stale) + ) + UPDATE freight.train_schedules SET deleted_at = now() + WHERE id IN (SELECT id FROM stale)`, + [originCode, destCode, departure.toISOString()], + ); + // Unpinned leftovers from earlier files would contaminate this day's batch. + await db( + `UPDATE freight.bookings b SET status = 'EXPIRED' + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%' + AND b.status = 'FULLY_EXECUTED' AND b.train_schedule_id IS NULL`, + ); +} + +/** + * Expire this suite's leftover unpaid holds. + * + * A company sitting on a SELECTED_FOR_BATCH / AWAITING_PAYMENT booking cannot + * create another one (contract-booking.service.ts assertNoUnpaidHold) — a real + * rule, and the reason each spec file must start from a clean tenant. Scoped to + * `CTR-IT-%` contracts, so no other suite's data is ever touched. + */ +export async function releaseUnpaidHolds(): Promise { + await db( + `UPDATE freight.bookings b + SET status = 'EXPIRED', train_schedule_id = NULL, scheduling_status = 'NOT_SCHEDULED', + -- Dropping the shipment day is what makes the retirement stick: the + -- stranded-PAID sweep re-places any unlinked booking that still has + -- payment_status PAID and a scheduled_date on the day being filled. + scheduled_date = NULL + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%' + AND b.status IN ('SELECTED_FOR_BATCH','AWAITING_PAYMENT','FULLY_EXECUTED','PAID')`, + ); + // Same treatment for fixtures that were ALREADY terminal: a booking the + // engine expired (or an earlier run retired) still carries payment_status + // PAID and its shipment day, which is all rescueStrandedPaidForDay needs to + // put it back on today's train. The status filter above never sees those + // rows, so sweep them here. + await db( + `UPDATE freight.bookings b SET scheduled_date = NULL + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IT-%' + AND b.train_schedule_id IS NULL AND b.scheduled_date IS NOT NULL + AND b.status IN ('EXPIRED','CANCELLED','REJECTED')`, + ); + // Physical wagon stock is finite and every earlier file's allocations still + // hold theirs (bookings that PAID and never "arrived" keep their wagons). + // Freeing them here is safe because this runs at file start, before the file + // books anything of its own — and without it the fifth or sixth file on the + // same stack silently gets a short consist. + await db( + `UPDATE freight.wagon_booking_allocations wba SET deleted_at = now() + FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL + AND ct.reference LIKE 'CTR-IT-%'`, + ); + // train_schedule_bookings.booking_id is UNIQUE and the constraint ignores + // deleted_at, so a soft-unlinked booking can never be re-batched: on a warm + // DB the next batch dies with "already exists for 'Booking Id'". These rows + // belong to retired fixture bookings, so drop them outright. + await db( + `DELETE FROM freight.train_schedule_bookings tsb + USING freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE tsb.booking_id = b.id AND ct.reference LIKE 'CTR-IT-%' + AND b.status IN ('EXPIRED','CANCELLED','REJECTED')`, + ); +} + +/** + * Ops creates a loco-pair schedule — capacity comes from maxWagonsPerTrain + * (loco-pair mode); wagon stock is drawn from the origin yard at allocation. + * `kind` picks the wagon family: container → NW5, bulk → CW4. + */ +export async function createSchedule(opts: { + departure: Date; + maxWagons?: number; + locoPair?: [string, string]; + kind?: "container" | "bulk"; + originCode?: string; + destCode?: string; +}): Promise { + const originCode = opts.originCode ?? ORIGIN; + const destCode = opts.destCode ?? DEST; + const existing = await findSchedule(opts.departure, originCode, destCode); + if (!existing) { + const route = await routeId(originCode, destCode); + if (!route) throw new Error(`route ${originCode}→${destCode} missing`); + const locos = await db<{ id: string }>( + `SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`, + [opts.locoPair ?? ["LOCO-IMP-1", "LOCO-IMP-2"]], + ); + if (locos.length !== 2) throw new Error("schedule locomotives missing"); + await apiOk(opsStaff, "post", `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, { + routeId: route, + scheduleDate: opts.departure.toISOString(), + locomotiveIds: locos.map((l) => l.id), + maxWagonsPerTrain: opts.maxWagons ?? 54, + }); + } + const schedule = await findSchedule(opts.departure, originCode, destCode); + if (!schedule) throw new Error("schedule was not created"); + return schedule; +} + +/** Back-compat alias for the container-import payment specs. */ +export const createImportSchedule = (opts: { + departure: Date; + maxWagons?: number; + locoPair?: [string, string]; +}) => createSchedule(opts); + +export function scheduleRow(scheduleId: string) { + return db( + `SELECT ${SCHEDULE_COLS} , ts.window_opens_at, ts.window_closes_at, ts.payment_phase_ends_at + FROM freight.train_schedules ts WHERE ts.id = $1`, + [scheduleId], + ).then((rows) => rows[0]); +} + +async function pollSchedulePhase(scheduleId: string, want: string[], attempts = 40) { + return poll<{ window_phase: string; booking_window_status: string }>( + `schedule ${scheduleId} → ${want.join("|")}`, + `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + (row) => !!row && want.includes(row.window_phase), + { attempts }, + ); +} + +/** Pull window_opens_at into the past; the app's 10s tick flips PRE_WINDOW→OPEN. */ +export async function forceWindowOpen(scheduleId: string, closesInMinutes = 45): Promise { + await db( + `UPDATE freight.train_schedules + SET window_opens_at = now() - interval '1 minute', + window_closes_at = now() + ($2 || ' minutes')::interval + WHERE id = $1`, + [scheduleId, String(closesInMinutes)], + ); + await pollSchedulePhase(scheduleId, ["OPEN"]); + await poll( + `schedule ${scheduleId} bookable`, + `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + (row) => (row as { booking_window_status?: string })?.booking_window_status === "OPEN", + ); +} + +export async function closeBookingWindow(scheduleId: string): Promise { + await db( + `UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second' + WHERE id = $1 AND window_phase = 'OPEN'`, + [scheduleId], + ); + await pollSchedulePhase(scheduleId, ["DOC_REVIEW"]); +} + +/** + * Staff end document review early → PAYMENT: the priority batch runs over the + * route-day pool, reserves wagons and ISSUES THE INVOICES this suite pays. + */ +export async function completeDocReview(scheduleId: string): Promise { + await apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`); + await pollSchedulePhase(scheduleId, ["PAYMENT", "DONE", "PRE_WINDOW"]); +} + +// --------------------------------------------------------------------------- +// bookings +// --------------------------------------------------------------------------- + +export interface BookingRow { + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + contract_id: string; + [k: string]: unknown; +} + +export async function bookingFor(contractId: string): Promise { + const rows = await db( + `SELECT b.id, b.reference, b.status, b.scheduling_status, b.train_schedule_id, + b.payment_deadline, b.contract_id + FROM freight.bookings b + WHERE b.contract_id = $1 AND b.deleted_at IS NULL + ORDER BY b.created_at DESC LIMIT 1`, + [contractId], + ); + if (!rows[0]) throw new Error(`no booking under contract ${contractId}`); + return rows[0]; +} + +export function pollBookingStatus(bookingId: string, status: string | string[], attempts = 30) { + const want = Array.isArray(status) ? status : [status]; + return poll( + `booking ${bookingId} → ${want.join("|")}`, + `SELECT status, scheduling_status FROM freight.bookings WHERE id = $1`, + [bookingId], + (row) => !!row && want.includes(row.status), + { attempts }, + ); +} + +/** Customer books containers under a seeded contract. */ +export async function bookContainers(opts: { + contractId: string; + runStamp: string; + isoSeed: number; + twenty?: number; + forty?: number; + scheduledDate: string; + vgmTons?: number; + as?: string; +}) { + const vgm = opts.vgmTons ?? 10; + const lines: Array> = []; + let unit = 0; + const line = (size: string, qty: number) => ({ + containerSize: size, + quantity: qty, + units: Array.from({ length: qty }, () => ({ + containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++), + vgmTons: vgm, + })), + }); + if (opts.twenty) lines.push(line("20ft", opts.twenty)); + if (opts.forty) lines.push(line("40ft", opts.forty)); + + return api(opts.as ?? customerA, "post", `/api/contracts/${opts.contractId}/bookings`, { + scheduledDate: opts.scheduledDate, + containers: lines, + }); +} + +/** + * Upload one ad-hoc doc → GL approves → finalize → customer proceeds with the + * shipment day. The e2e seed configures no required documents, so a single doc + * satisfies the 100%-approved gate. + */ +export async function clearBooking( + bookingId: string, + scheduledDate: string, + as = customerA, +): Promise { + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e"); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/finalize`); + await apiOk(as, "post", `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate }); + await pollBookingStatus(bookingId, "OPERATION_REQUEST_PENDING", 20); +} + +/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */ +export async function acceptOperation(bookingId: string): Promise { + await apiOk(opsStaff, "post", `/api/bookings/${bookingId}/operation/review`, { + decision: "ACCEPT", + }); + await pollBookingStatus(bookingId, "FULLY_EXECUTED", 15); +} + +// --------------------------------------------------------------------------- +// invoices +// --------------------------------------------------------------------------- + +export interface InvoiceRow { + id: string; + invoice_number: string; + status: string; + total_amount: string; + balance_amount: string | null; + paid_amount: string | null; + currency: string; + payment_id: string | null; + source: string; + source_id: string; + paid_at: string | null; + [k: string]: unknown; +} + +export function invoiceForBooking(bookingId: string, attempts = 30) { + return poll( + `invoice for booking ${bookingId}`, + `SELECT * FROM freight.invoices + WHERE source_id = $1 AND source = 'booking' AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [bookingId], + (row) => !!row, + { attempts }, + ); +} + +export function currentInvoice(invoiceId: string) { + return db(`SELECT * FROM freight.invoices WHERE id = $1`, [invoiceId]).then( + (rows) => rows[0], + ); +} + +/** The freight-side projection of a gateway intent. */ +export function freightPayment(intentId: string) { + return db<{ id: string; status: string; merchant_order_id: string; transaction_id: string | null }>( + `SELECT id, status, merchant_order_id, transaction_id FROM freight.payments WHERE id = $1`, + [intentId], + ).then((rows) => rows[0]); +} + +// --------------------------------------------------------------------------- +// payment +// --------------------------------------------------------------------------- + +/** + * Customer pays an invoice from the portal. This is the real chain: + * portal → billing.payInvoice → PaymentClientService → payment API + * → provider (gateway mock) → intent, and the invoice gets `payment_id`. + */ +export function payInvoice( + invoiceId: string, + opts: { method?: string; as?: string; payerAccount?: string } = {}, +) { + return api(opts.as ?? customerA, "post", `/api/billing/my-invoices/${invoiceId}/pay`, { + method: opts.method ?? "CBE_BIRR", + platform: "web", + ...(opts.payerAccount ? { payerAccount: opts.payerAccount } : {}), + }); +} + +/** The gateway intent the payment service opened for a booking. */ +export function gatewayIntent(bookingId: string, attempts = 15) { + return poll<{ + id: string; + status: string; + merchant_order_id: string; + amount_minor: string; + currency: string; + provider: string; + expires_at: string | null; + }>( + `payment intent for booking ${bookingId}`, + `SELECT id, status, merchant_order_id, amount_minor, currency, provider, expires_at + FROM edr_payment.payment_intent + WHERE reference_id = $1 ORDER BY created_at DESC LIMIT 1`, + [bookingId], + (row) => !!row, + { attempts, intervalMs: 1000 }, + ); +} + +// --------------------------------------------------------------------------- +// the whole arrange chain, in one call +// --------------------------------------------------------------------------- + +export interface ReadyBooking { + contractId: string; + bookingId: string; +} + +/** + * Contract → booking → clearance → ops accept, ending in the day pool + * (FULLY_EXECUTED). Invoices are not issued yet: that happens when the batch + * runs — see {@link runBatch}. Roughly 30–60s of real API work per booking. + */ +export async function prepareBooking(opts: { + suffix: string; + departure: Date; + runStamp: string; + isoSeed: number; + twenty?: number; + forty?: number; + currency?: "ETB" | "USD"; + tin?: string; + as?: string; +}): Promise { + const contractId = await seedContract({ + suffix: opts.suffix, + reference: `CTR-IT-${opts.runStamp}-${opts.suffix}`, + currency: opts.currency, + tin: opts.tin, + }); + const day = eatDayStr(opts.departure); + const res = await bookContainers({ + contractId, + runStamp: opts.runStamp, + isoSeed: opts.isoSeed, + twenty: opts.twenty ?? 2, + forty: opts.forty, + scheduledDate: day, + as: opts.as, + }); + if (res.status > 201) { + throw new Error(`booking ${opts.suffix} rejected: ${res.status} ${JSON.stringify(res.body)}`); + } + const booking = await bookingFor(contractId); + await clearBooking(booking.id, day, opts.as ?? customerA); + await acceptOperation(booking.id); + return { contractId, bookingId: booking.id }; +} + +/** + * Close the booking window and end doc review — the batch reserves wagons by + * priority and issues the invoices. Everything pooled for the day settles here. + */ +export async function runBatch(scheduleId: string): Promise { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); +} + +// --------------------------------------------------------------------------- +// tenants — one company per booking +// --------------------------------------------------------------------------- + +/** + * A company may hold only ONE unpaid reservation at a time + * (contract-booking.service.ts assertNoUnpaidHold, company-scoped). Every + * multi-booking scenario here therefore needs a company per booking: without + * it the second booking of any train-filling scenario is rejected with + * "You already have a booking waiting for payment". + * + * Staff (`superAdmin`) book and pay on the customer's behalf, so these tenants + * need no portal user — which is also the real Path B flow for customs. + */ +export async function ensureTenant(slug: string): Promise { + const tin = tinFor(slug); + await db( + `WITH c AS ( + INSERT INTO freight.companies + (id, name, type, status, tin, fan_number, country, address, phone, email, + nationality, kind, attributes) + SELECT gen_random_uuid(), 'IT Tenant ' || $1::text, 'customer', 'active', $2::text, + $2::text || '000000', 'Ethiopia', 'Addis Ababa', + '+2519' || substr($2::text, 3, 8), + 'ops+' || lower($1::text) || '@it-tenant.test', 'ethiopian', 'commercial', '{}'::jsonb + WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = $2::text) + RETURNING id + ), pick AS ( + SELECT id FROM c + UNION ALL + SELECT id FROM freight.companies WHERE tin = $2::text + LIMIT 1 + ) + INSERT INTO freight.company_profiles (id, company_id, type, status, reference) + SELECT gen_random_uuid(), pick.id, v.type, 'active', upper(substr(v.type, 1, 3)) || $2::text + FROM pick CROSS JOIN (VALUES ('importer'), ('exporter')) AS v(type) + WHERE NOT EXISTS ( + SELECT 1 FROM freight.company_profiles p + WHERE p.company_id = pick.id AND p.type = v.type + )`, + [slug, tin], + ); + return tin; +} + +/** Deterministic 10-digit TIN per tenant slug — stable across runs, unique per slug. */ +export function tinFor(slug: string): string { + let h = 0; + for (const ch of slug) h = (h * 31 + ch.charCodeAt(0)) % 100_000_000; + return `20${String(h).padStart(8, "0")}`; +} + +/** Seed one tenant + one contract per suffix, and return suffix → contractId. */ +export async function seedTenantContracts( + runStamp: string, + specs: Array< + { suffix: string } & Omit + >, +): Promise> { + const out = new Map(); + for (const spec of specs) { + const tin = await ensureTenant(`${runStamp}-${spec.suffix}`); + out.set( + spec.suffix, + await seedContract({ + ...spec, + reference: `CTR-IT-${runStamp}-${spec.suffix}`, + tin, + }), + ); + } + return out; +} + +// --------------------------------------------------------------------------- +// bulk bookings +// --------------------------------------------------------------------------- + +/** + * Book bulk tons under a seeded BULK contract. Wagon demand = ceil(tons / 70) + * on CW4 covered gondolas. Omit `scheduledDate` for DOMESTIC (intercity) + * bookings — they never pick a shipment day. + */ +export async function bookBulk(opts: { + contractId: string; + tons: number; + scheduledDate?: string; + cargoCode?: string; + as?: string; +}) { + const [row] = await db<{ cargo_type_id: string }>( + `SELECT t.id AS cargo_type_id FROM freight.cargo_types t WHERE t.code = $1`, + [opts.cargoCode ?? "E2E_IMP_WHEAT"], + ); + if (!row) throw new Error(`cargo type ${opts.cargoCode ?? "E2E_IMP_WHEAT"} not seeded`); + return api(opts.as ?? superAdmin, "post", `/api/contracts/${opts.contractId}/bookings`, { + ...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}), + bulkLines: [{ cargoTypeId: row.cargo_type_id, cargoWeightTons: opts.tons }], + cargoFreeText: "IT corridor wheat", + }); +} + +/** + * Book a PER_ITEM break-bulk line (automobiles / machinery from + * seed-bulk-items.sql). Wagon demand is the greater of the per-item floor and + * the tonnage math — which is exactly what these scenarios pin down. + */ +export async function bookBulkItems(opts: { + contractId: string; + cargoCode: "E2E_IMP_AUTO" | "E2E_IMP_MACHINE"; + items: number; + tons: number; + scheduledDate?: string; + hazardousQuantity?: number; + reeferQuantity?: number; + as?: string; +}) { + const [row] = await db<{ cargo_type_id: string }>( + `SELECT t.id AS cargo_type_id FROM freight.cargo_types t WHERE t.code = $1`, + [opts.cargoCode], + ); + if (!row) throw new Error(`cargo type ${opts.cargoCode} not seeded`); + return api(opts.as ?? superAdmin, "post", `/api/contracts/${opts.contractId}/bookings`, { + ...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}), + bulkLines: [ + { + cargoTypeId: row.cargo_type_id, + itemCount: opts.items, + cargoWeightTons: opts.tons, + ...(opts.hazardousQuantity != null ? { hazardousQuantity: opts.hazardousQuantity } : {}), + ...(opts.reeferQuantity != null ? { reeferQuantity: opts.reeferQuantity } : {}), + }, + ], + cargoFreeText: `IT break-bulk ${opts.cargoCode}`, + }); +} + +/** Book PER_ITEM cargo and walk it to the pool. */ +export async function bookBulkItemsReady(opts: { + contractId: string; + cargoCode: "E2E_IMP_AUTO" | "E2E_IMP_MACHINE"; + items: number; + tons: number; + scheduledDate: string; +}): Promise { + const res = await bookBulkItems(opts); + if (res.status > 201) { + throw new Error(`break-bulk booking rejected: ${res.status} ${JSON.stringify(res.body)}`); + } + const booking = await bookingFor(opts.contractId); + await clearBooking(booking.id, opts.scheduledDate, superAdmin); + await acceptOperation(booking.id); + return booking.id; +} + +/** + * Type-integrity check for mixed trains: every wagon slot under the booking is + * of ONE expected type (containers → NW5, bulk → CW4) and the count matches. + * No wheat on a flat wagon, no box in a gondola. + */ +export async function expectWagonType(bookingId: string, code: string, wagons: number) { + const row = await poll<{ code: string; n: string }>( + `booking ${bookingId} rides ${wagons}× ${code}`, + `SELECT wt.code, count(*)::text AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL + GROUP BY wt.code`, + [bookingId], + (r) => r?.code === code && Number(r?.n) === wagons, + { attempts: 20 }, + ); + const [kinds] = await db<{ k: string }>( + `SELECT count(DISTINCT tsw.wagon_type_id)::text AS k + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`, + [bookingId], + ); + if (Number(kinds.k) !== 1) { + throw new Error(`booking ${bookingId} rides ${kinds.k} wagon types, expected 1`); + } + return row; +} + +/** Book + walk the clearance gate + ops accept, ending FULLY_EXECUTED in the pool. */ +export async function bookBulkReady(opts: { + contractId: string; + tons: number; + scheduledDate: string; + mode?: "import" | "export"; + /** Path B — walk the phased chain instead of the one-shot finalize. */ + customs?: boolean; +}): Promise { + const res = await bookBulk(opts); + if (res.status > 201) { + throw new Error(`bulk booking rejected: ${res.status} ${JSON.stringify(res.body)}`); + } + const booking = await bookingFor(opts.contractId); + if (opts.customs && opts.mode === "export") { + await clearBookingPhasedCustomsExport(booking.id, opts.scheduledDate); + } else if (opts.customs) { + await clearBookingPhasedCustoms(booking.id, opts.scheduledDate); + } else { + await clearBooking(booking.id, opts.scheduledDate, superAdmin); + } + if (opts.mode === "export") await acceptExport(booking.id); + else await acceptOperation(booking.id); + return booking.id; +} + +/** + * Book, walk the clearance gate, and expect the shipment-day request to be + * REFUSED. + * + * The window and export-space gates no longer sit on booking creation — the + * booking is born in the clearance gate and the day is only validated when the + * customer picks it (`clearance/proceed` → requestOperation, + * contract-booking.service.ts). So "rejected at submission" now means rejected + * at the day request; creation itself succeeds. + */ +export async function expectDayRefused(opts: { + contractId: string; + tons: number; + scheduledDate: string; +}): Promise<{ status: number; body: unknown }> { + const res = await bookBulk(opts); + if (res.status > 201) return { status: res.status, body: res.body }; + + const booking = await bookingFor(opts.contractId); + await upload(superAdmin, `/api/bookings/${booking.id}/clearance/documents`, DOC_FIXTURE, "custom_e2e"); + await apiOk(superAdmin, "post", `/api/bookings/${booking.id}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }); + await apiOk(superAdmin, "post", `/api/bookings/${booking.id}/clearance/finalize`); + const proceed = await api(superAdmin, "post", `/api/bookings/${booking.id}/clearance/proceed`, { + scheduledDate: opts.scheduledDate, + }); + return { status: proceed.status, body: proceed.body }; +} + +/** Ops accepts an EXPORT operation request — FCFS: the accept itself reserves. */ +export async function acceptExport(bookingId: string): Promise { + await apiOk(opsStaff, "post", `/api/bookings/${bookingId}/operation/review`, { + decision: "ACCEPT", + }); + await pollBookingStatus(bookingId, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 15); +} + +/** + * Walk a customs (Path B) IMPORT booking through the PHASED clearance chain. + * + * `clearance/finalize` refuses these outright ("General customs bookings use + * phased clearance…") — the Cypress suite documents that gap and leaves its + * customs bookings failing. The real chain, GL Ethiopia and GL Djibouti in + * turn, is: + * docs upload → review APPROVED → request transit assignee → assign it → + * customs declaration → duty advice (none due) → transit permit → + * finalize pre-clearance → delivery order (DO_COLLECTED → ready for + * operation) → customer picks the shipment day. + */ +export async function clearBookingPhasedCustoms( + bookingId: string, + scheduledDate: string, +): Promise { + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e"); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }); + + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/transit-assignee/request`, { + note: "it", + }); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/transit-assignee/assign`, { + transitAgentId: await ensureTransitAgent(), + }); + + // GL ET sends a priced draft first; the customer accepts it before the real + // declaration may be filed (DRAFT_DECLARATION_SENT gates pre-clearance). + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/draft-declaration`, DOC_FIXTURE, "files", { + price: "1000", + currency: "ETB", + }); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/draft-declaration/accept`); + + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/declaration`, DOC_FIXTURE, "declaration"); + // No duty due — the slip step is then skipped by the workflow itself. + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/duty`, DOC_FIXTURE, "attachment", { + dutyRequired: "false", + }); + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/transit-permit`, DOC_FIXTURE, "transit_permit"); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/finalize-pre-clearance`); + + const vesselArrival = new Date(Date.now() - 24 * 3_600_000).toISOString().slice(0, 10); + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/delivery-order`, DOC_FIXTURE, "file", { + vesselArrivalDate: vesselArrival, + doCollectedDate: vesselArrival, + }); + + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate }); + await pollBookingStatus(bookingId, "OPERATION_REQUEST_PENDING", 20); +} + +/** + * EXPORT half of the phased customs chain: no transit assignee and no duty — + * GL ET files the declaration, GL DJ secures the Release Order (which must + * lead the vessel departure by the configured minimum), and that release is + * what unlocks the shipment day. + */ +export async function clearBookingPhasedCustomsExport( + bookingId: string, + scheduledDate: string, +): Promise { + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e"); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }); + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/declaration`, DOC_FIXTURE, "declaration"); + + const vesselDeparture = new Date(Date.now() + 7 * 24 * 3_600_000).toISOString().slice(0, 10); + const ro = await upload( + superAdmin, + `/api/bookings/${bookingId}/clearance/release-order`, + DOC_FIXTURE, + "file", + { vesselDepartureDate: vesselDeparture }, + ); + if (ro.status > 201) { + throw new Error(`release order → ${ro.status}: ${JSON.stringify(ro.body)}`); + } + + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/proceed`, { scheduledDate }); + await pollBookingStatus(bookingId, "OPERATION_REQUEST_PENDING", 20); +} + +/** One reusable transit officer — the roster is empty in a fresh stack. */ +export async function ensureTransitAgent(): Promise { + const [existing] = await db<{ id: string }>( + `SELECT id FROM freight.transit_agents + WHERE is_active AND deleted_at IS NULL AND valid_from <= now() AND valid_to >= now() + LIMIT 1`, + ); + if (existing) return existing.id; + const [created] = await db<{ id: string }>( + `INSERT INTO freight.transit_agents (name, valid_from, valid_to, is_active) + VALUES ('IT Transit Officer', now() - interval '1 day', now() + interval '1 year', true) + RETURNING id`, + ); + return created.id; +} + +/** DOMESTIC bookings have no shipment day — finalize alone lands FULLY_EXECUTED. */ +export async function clearIntercityBooking(bookingId: string): Promise { + await upload(superAdmin, `/api/bookings/${bookingId}/clearance/documents`, DOC_FIXTURE, "custom_e2e"); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }); + await apiOk(superAdmin, "post", `/api/bookings/${bookingId}/clearance/finalize`); + await pollBookingStatus(bookingId, "FULLY_EXECUTED", 15); +} + +// --------------------------------------------------------------------------- +// batch engine controls +// --------------------------------------------------------------------------- + +/** Batch fill reserves by priority DESC — order 1 = first pick. */ +export function setPriority(bookingId: string, order: number) { + return db(`UPDATE freight.bookings SET priority_score = $2 WHERE id = $1`, [ + bookingId, + 1000 - order, + ]); +} + +/** + * Push the pay window out for the bookings a test intends to PAY. + * + * A reservation's deadline is ~60s in this stack. Paying through the real + * gateway (initiate → provider → signed callback → broker → settle) plus the + * allocation wait costs more than that when several bookings pay in sequence, + * so on a loaded stack the last hold expires before its payment lands and the + * engine strands it ("re-placed stranded PAID booking …"). Widening the window + * is arrange, not assertion: the tests that are ABOUT expiry never call this. + */ +export async function extendPayWindow( + scheduleId: string, + bookingIds: string[], + minutes = 30, +): Promise { + await db( + `UPDATE freight.bookings SET payment_deadline = now() + ($2 || ' minutes')::interval + WHERE id = ANY($1::uuid[])`, + [bookingIds, String(minutes)], + ); + await db( + `UPDATE freight.train_schedules + SET payment_phase_ends_at = GREATEST( + COALESCE(payment_phase_ends_at, now()), + now() + ($2 || ' minutes')::interval) + WHERE id = $1`, + [scheduleId, String(minutes)], + ); +} + +/** End the payment phase now — the tick settles (allocate paid / expire unpaid). */ +export function endPaymentPhase(scheduleId: string) { + return db( + `UPDATE freight.train_schedules + SET payment_phase_ends_at = now() - interval '1 second' + WHERE id = $1 AND window_phase = 'PAYMENT'`, + [scheduleId], + ); +} + +/** + * Push a reservation's pay deadline an hour into the past and wait for the + * engine to expire it. + * + * An hour, not a second: promoting a waiting booking extends the payment phase + * (extendPaymentPhaseForTopUp), and a deadline only just behind `now()` can end + * up on the wrong side of that move. Expiry also requires the payment service + * to answer the reconcile-before-expire question — which it really does here. + */ +export async function forceReservationExpiry(bookingId: string): Promise { + await db( + `UPDATE freight.bookings SET payment_deadline = now() - interval '1 hour' WHERE id = $1`, + [bookingId], + ); + await pollBookingStatus(bookingId, "EXPIRED", 40); +} + +/** + * Allocation is driven by the schedule's settle tick, not by the payment + * response — on a busy stack (full-suite run) that tick can be several cycles + * behind, so wait generously rather than flake. + */ +export function pollAllocations(bookingId: string, minWagons = 1) { + return poll<{ n: string }>( + `booking ${bookingId} wagon allocations >= ${minWagons}`, + `SELECT count(*)::text AS n FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + (row) => Number(row?.n ?? 0) >= minWagons, + { attempts: 45, intervalMs: 3000 }, + ); +} + +/** Distinct wagon slots committed to a schedule — the consist occupancy. */ +export async function allocatedWagons(scheduleId: string): Promise { + const [row] = await db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id)::text AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + return Number(row.n); +} + +/** The open partial-split offer the batch made a booking that did not fit whole. */ +export function pollPartialOffer(bookingId: string) { + return poll<{ status: string; offered_wagons: number }>( + `booking ${bookingId} partial offer`, + `SELECT status, offered_wagons FROM freight.booking_batch_offers + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [bookingId], + (row) => row?.status === "OFFERED", + { attempts: 15 }, + ); +} + +/** + * Wait for the payment phase to settle out. + * + * Only a FULL train rests at DONE. A day that ends under-filled CONCLUDES and + * REOPENS — window_phase goes back to OPEN on the next cycle — so waiting for + * DONE on an under-filled train waits forever. + */ +export const pollCycleConcluded = (scheduleId: string) => + pollWindow(scheduleId, (s) => s.window_phase !== "PAYMENT", "payment phase concluded"); + +export function pollWindow(scheduleId: string, check: (row: ScheduleRow) => boolean, label: string) { + return poll( + `schedule ${scheduleId} ${label}`, + `SELECT ${SCHEDULE_COLS} FROM freight.train_schedules ts WHERE ts.id = $1`, + [scheduleId], + (row) => !!row && check(row), + { attempts: 40 }, + ); +} + +// --------------------------------------------------------------------------- +// paying through the REAL gateway +// --------------------------------------------------------------------------- + +/** + * Settle a reservation the way production does: initiate on the invoice + * (freight → payment service → provider), then have the gateway call back. + * + * This replaces the Cypress suite's staff `mark-paid` shortcut everywhere — + * mark-paid skips `booking.invoice.paid`, so it never applies a pending split + * offer and never proves the payment seam. Uses the staff initiate endpoint + * (`/api/payments/initiate`) because these fixtures are booked on the + * customer's behalf and have no portal user of their own. + */ +export async function payViaGateway( + bookingId: string, + opts: { method?: string; expectStatus?: string[] } = {}, +): Promise { + const invoice = await invoiceForBooking(bookingId); + const res = await api(opsStaff, "post", "/api/payments/initiate", { + invoiceId: invoice.id, + method: opts.method ?? "CBE_BIRR", + platform: "web", + }); + if (res.status > 201) { + throw new Error(`initiate for ${bookingId} → ${res.status}: ${JSON.stringify(res.body)}`); + } + const intent = await gatewayIntent(bookingId); + const hook = await gateway.webhook({ merchantOrderId: intent.merchant_order_id }); + if (hook.body?.delivered !== 200) { + throw new Error(`webhook delivery failed: ${JSON.stringify(hook.body)}`); + } + await poll( + `invoice ${invoice.invoice_number} PAID`, + `SELECT status FROM freight.invoices WHERE id = $1`, + [invoice.id], + (row) => (row as { status?: string })?.status === "PAID", + { attempts: 30 }, + ); + await pollBookingStatus(bookingId, opts.expectStatus ?? ["PAID", "IN_TRANSIT", "ARRIVED"], 30); +} + +// --------------------------------------------------------------------------- +// train journey + customs tail +// --------------------------------------------------------------------------- + +export const gatePassGranted = (scheduleId: string) => + apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/import-djibouti/gatepass-granted`); + +export const finalizeSchedule = (scheduleId: string) => + apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/finalize`); + +export const dispatchSchedule = (scheduleId: string) => + apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/dispatch`); + +export const recordCheckpoint = (scheduleId: string, sequenceNo: number, kind: string) => + apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/checkpoints`, { + sequenceNo, + kind, + }); + +/** Run the corridor: four PASSED checkpoints, then ARRIVED at the terminal. */ +export async function runCorridor(scheduleId: string): Promise { + for (const seq of [1, 2, 3, 4]) await recordCheckpoint(scheduleId, seq, "PASSED"); + await recordCheckpoint(scheduleId, 5, "ARRIVED"); + await pollWindow(scheduleId, (s) => s.status === "ARRIVED", "ARRIVED"); +} + +export const uploadT1 = (bookingId: string) => + upload(superAdmin, `/api/contracts/bookings/${bookingId}/t1-documents`, DOC_FIXTURE); + +export const uploadTransportDocument = (bookingId: string) => + upload(superAdmin, `/api/contracts/bookings/${bookingId}/transport-document`, DOC_FIXTURE); + +export const closeT1 = (bookingId: string) => + apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/t1-close`); + +/** Post-arrival import tail: T1 close → risk → second duty → final invoice. */ +export async function runImportCustomsTail(bookingId: string): Promise { + await closeT1(bookingId); + await apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/risk`, { + riskLevel: "GREEN", + }); + await upload(superAdmin, `/api/contracts/bookings/${bookingId}/second-duty`, DOC_FIXTURE, "attachment", { + dutyRequired: "false", + }); + await upload(superAdmin, `/api/contracts/bookings/${bookingId}/final-invoice`, DOC_FIXTURE, "file", { + amount: "1000", + description: "it final invoice", + }); + // Issue → approve → customer pays → GL confirms. The slip is rejected while + // the invoice is still a draft. + await apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/final-invoice/approve`); + const slip = await upload( + superAdmin, + `/api/contracts/bookings/${bookingId}/final-invoice-slip`, + DOC_FIXTURE, + "file", + ); + if (slip.status > 201) { + throw new Error(`final invoice slip → ${slip.status}: ${JSON.stringify(slip.body)}`); + } + await apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/final-invoice/confirm`); + await completeMilestone(bookingId, "IMPORT_RELEASE_GRANTED"); + await completeMilestone(bookingId, "IMPORT_PROCESS_COMPLETED"); +} + +export const completeMilestone = (bookingId: string, code: string) => + apiOk(superAdmin, "post", `/api/contracts/bookings/${bookingId}/milestones/${code}/complete`, { + note: "it", + }); + +export function expectMilestoneDone(bookingId: string, code: string) { + return poll<{ n: string }>( + `booking ${bookingId} milestone ${code}`, + `SELECT count(*)::text AS n FROM freight.clearance_milestones + WHERE booking_id = $1 AND milestone_code = $2 AND status = 'COMPLETED' AND deleted_at IS NULL`, + [bookingId, code], + (row) => Number(row?.n ?? 0) > 0, + { attempts: 15 }, + ); +} + +export async function milestoneCount(bookingId: string, code: string): Promise { + const [row] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.clearance_milestones + WHERE booking_id = $1 AND milestone_code = $2 AND status = 'COMPLETED' AND deleted_at IS NULL`, + [bookingId, code], + ); + return Number(row.n); +} + +export const acceptIntercityOnto = (scheduleId: string, bookingId: string) => + apiOk(opsStaff, "post", `/api/train-scheduling/schedules/${scheduleId}/intercity/accept`, { + bookingIds: [bookingId], + }); + +// --------------------------------------------------------------------------- +// GROUP 1 — wagon arithmetic on the 53-wagon BUILT container train +// --------------------------------------------------------------------------- + +/** The Group 1 built train's consist size — see seed-g1-train.sql. */ +export const G1_WAGONS = 53; +export const G1_TRAIN = "TRN-G1-1"; + +/** seed-government.sql — the kind='government' company POST /api/bookings needs. */ +export const GOV_COMPANY_ID = "0a1b0001-0000-4000-8000-000000000001"; +export const GOV_PROFILE_ID = "0b1c0001-0000-4000-8000-000000000001"; + +/** + * Wagons a container booking needs: two 20ft share a wagon, a 40ft takes one. + * An odd 20ft count still costs a whole wagon — and `assert20ftPairable` + * refuses to submit one — so every scenario keeps 20ft quantities even. + */ +export const containerWagons = (twenty = 0, forty = 0) => Math.ceil(twenty / 2) + forty; + +/** + * Schedule the BUILT train rather than a locomotive pair. + * + * `maxWagonsPerTrain` is not a real cap on the pair path: syncScheduleMaxWagons + * recomputes max_wagons from locomotive length (54 on this corridor) every fill + * pass. A built train's coupled consist wins outright + * (`physicalWagons ?? capacityLimits(loco)`), so the 53 wagons seed-g1-train.sql + * couples ARE the capacity — which is the number Group 1's arithmetic is + * written in. The count is asserted here: a 52-wagon consist would shift every + * scenario by a slot and fail far from the cause. + */ +export async function createBuiltTrainSchedule(opts: { + departure: Date; + trainCode?: string; + wagons?: number; +}): Promise { + const trainCode = opts.trainCode ?? G1_TRAIN; + if (!(await findSchedule(opts.departure))) { + const route = await routeId(); + if (!route) throw new Error(`route ${ORIGIN}→${DEST} missing`); + const [train] = await db<{ id: string }>( + `SELECT id FROM freight.trains WHERE code = $1`, + [trainCode], + ); + if (!train) throw new Error(`built train ${trainCode} missing — seed-g1-train.sql`); + await apiOk(opsStaff, "post", "/api/train-scheduling/container/schedules", { + routeId: route, + scheduleDate: opts.departure.toISOString(), + trainId: train.id, + }); + } + const schedule = await findSchedule(opts.departure); + if (!schedule) throw new Error("built-train schedule was not created"); + const wagons = opts.wagons ?? G1_WAGONS; + if (Number(schedule.max_wagons) !== wagons) { + throw new Error( + `${trainCode} scheduled with max_wagons=${schedule.max_wagons}, expected the ${wagons}-wagon consist`, + ); + } + return schedule; +} + +/** Book containers on the customer's behalf and walk them into the day pool. */ +export async function bookContainersReady(opts: { + contractId: string; + runStamp: string; + isoSeed: number; + twenty?: number; + forty?: number; + scheduledDate: string; + vgmTons?: number; + /** Path B contract — walk the phased chain instead of the one-shot finalize. */ + customs?: boolean; +}): Promise { + const res = await bookContainers({ ...opts, as: superAdmin }); + if (res.status > 201) { + throw new Error(`container booking rejected: ${res.status} ${JSON.stringify(res.body)}`); + } + const booking = await bookingFor(opts.contractId); + if (opts.customs) await clearBookingPhasedCustoms(booking.id, opts.scheduledDate); + else await clearBooking(booking.id, opts.scheduledDate, superAdmin); + await acceptOperation(booking.id); + return booking.id; +} + +/** Containers still on a booking — the number a split REDUCES. */ +export async function containerCount(bookingId: string): Promise { + const [row] = await db<{ q: string | null }>( + `SELECT sum(quantity)::text AS q FROM freight.booking_container + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return Number(row?.q ?? 0); +} + +/** Assert the batch never raised a partial offer — the whole-or-nothing cases. */ +export async function expectNoPartialOffer(bookingId: string, label = bookingId): Promise { + const [row] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.booking_batch_offers + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (Number(row.n) !== 0) throw new Error(`${label} was offered a split (${row.n} offer rows)`); +} + +/** + * Every container on the train mapped to a wagon slot, with a real number on it. + * + * Wagon counts alone cannot catch a half-done allocation: a booking whose + * wagons were reserved but whose units were never placed still reads as a full + * train. The units live in `wagon_allocation_container_items` — one row per + * container, and the marshalling sheet is generated from that column. + */ +export async function expectContainersPlaced(scheduleId: string, containers: number) { + const placed = poll<{ n: string }>( + `${containers} containers mapped to wagon slots on ${scheduleId}`, + `SELECT count(*)::text AS n + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations wba ON wba.id = ci.wagon_booking_allocation_id + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [scheduleId], + (row) => Number(row?.n ?? 0) === containers, + { attempts: 25 }, + ); + await placed; + const [blank] = await db<{ n: string }>( + `SELECT count(*)::text AS n + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations wba ON wba.id = ci.wagon_booking_allocation_id + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL AND tsb.deleted_at IS NULL + AND (ci.container_number IS NULL OR ci.container_number = '')`, + [scheduleId], + ); + if (Number(blank.n) !== 0) { + throw new Error(`${blank.n} slot(s) placed without a container number`); + } +} + +/** Bookings still linked to a schedule — the seats actually held. */ +export async function linkedBookings(scheduleId: string): Promise { + const [row] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.train_schedule_bookings + WHERE train_schedule_id = $1 AND deleted_at IS NULL`, + [scheduleId], + ); + return Number(row.n); +} + +/** Invoices still payable against a booking — an expiry must leave none. */ +export async function livePayableInvoices(bookingId: string): Promise { + const [row] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.invoices + WHERE source = 'booking' AND source_id = $1 AND deleted_at IS NULL + AND paid_at IS NULL + -- ::text because the enum has no VOID member; comparing the label + -- directly makes Postgres reject the whole query. + AND status::text NOT IN ('EXPIRED','CANCELLED','VOID')`, + [bookingId], + ); + return Number(row.n); +} + +/** The portal's own availability question for a shipment day. */ +export async function dayAvailability(bookingId: string, day: string) { + const res = await apiOk( + superAdmin, + "get", + `/api/bookings/${bookingId}/day-availability?date=${day}`, + ); + const body = res.body as { + trainsForDay?: boolean; + freeWagons?: number; + data?: { trainsForDay?: boolean; freeWagons?: number }; + }; + return body.data ?? body; +} + +/** + * An EXPIRED booking is recoverable without re-approval: the CONTRACT is still + * executed, so the customer can rebook a later day. Asserting the contract (not + * just the booking) is the point — a bug that retired it would strand them. + */ +export async function expectContractStillBookable(bookingId: string): Promise { + const booking = await bookingRow(bookingId); + const [contract] = await db<{ status: string }>( + `SELECT status FROM freight.contracts WHERE id = $1`, + [booking.contract_id], + ); + if (!["FULLY_EXECUTED", "CONTRACT_ACTIVE"].includes(contract.status)) { + throw new Error(`contract of ${bookingId} is ${contract.status}, no longer bookable`); + } +} + +/** + * Let a partial offer lapse instead of paying it. The offer dies with the + * booking's pay deadline, so pushing the deadline back is what the wall clock + * would do — and the settle tick then expires the booking WHOLE. + */ +export async function forceOfferLapse(bookingId: string): Promise { + await db( + `UPDATE freight.bookings SET payment_deadline = now() - interval '1 hour' WHERE id = $1`, + [bookingId], + ); + await pollBookingStatus(bookingId, "EXPIRED", 40); +} + +/** + * A government booking, created the way the product creates one: staff POST + * /api/bookings against the seeded kind='government' company (never the + * contract wizard), then `government-expedite` promotes it to PAID/Eligible — + * it rides without paying. + */ +export async function createGovernmentBooking(opts: { + forty: number; + vgmTons?: number; + scheduledDate?: string; +}): Promise { + const vgm = opts.vgmTons ?? 10; + const [origin] = await db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [ORIGIN]); + const [dest] = await db<{ id: string }>(`SELECT id FROM freight.yards WHERE code = $1`, [DEST]); + const [service] = await db<{ id: string }>( + `SELECT id FROM freight.service_types ORDER BY created_at LIMIT 1`, + ); + const [ctype] = await db<{ id: string }>( + `SELECT id FROM freight.container_types WHERE size_ft = 40 AND is_active LIMIT 1`, + ); + await apiOk(superAdmin, "post", "/api/bookings", { + isGovernment: true, + companyId: GOV_COMPANY_ID, + companyProfileId: GOV_PROFILE_ID, + contractType: "NEW", + serviceTypeId: service.id, + equipmentReturn: "WITHOUT_RETURN", + originYardId: origin.id, + destinationYardId: dest.id, + tradeDirection: "IMPORT", + freightType: "CONTAINER", + containers: [{ containerTypeId: ctype.id, quantity: opts.forty, vgmPerUnitTons: vgm }], + cargoTotalWeightVgm: opts.forty * vgm, + paymentCurrency: "USD", + ...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}), + }); + const [row] = await db<{ id: string }>( + `SELECT id FROM freight.bookings + WHERE company_id = $1 AND is_government = true AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1`, + [GOV_COMPANY_ID], + ); + if (!row) throw new Error("government booking was not created"); + return row.id; +} + +/** Idempotent retry path — create() already expedites, this re-asserts it. */ +export const governmentExpedite = (bookingId: string) => + apiOk(superAdmin, "post", `/api/bookings/${bookingId}/government-expedite`); + +/** Staff pin: fillSchedule's pool is keyed on `booking.train_schedule_id`. */ +export const pinToSchedule = (bookingId: string, scheduleId: string) => + db(`UPDATE freight.bookings SET train_schedule_id = $1 WHERE id = $2`, [scheduleId, bookingId]); + +/** Staff "run batch" button — a fill pass on demand, no phase change. */ +export const triggerBatchRun = (scheduleId: string) => + apiOk(superAdmin, "post", `/api/train-scheduling/schedules/${scheduleId}/run-batch`); + +/** Wipe this suite's leftover government bookings so "newest" is unambiguous. */ +export async function releaseGovernmentBookings(): Promise { + await db( + `UPDATE freight.wagon_booking_allocations wba SET deleted_at = now() + FROM freight.bookings b + WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL AND b.company_id = $1`, + [GOV_COMPANY_ID], + ); + await db( + `DELETE FROM freight.train_schedule_bookings tsb USING freight.bookings b + WHERE tsb.booking_id = b.id AND b.company_id = $1`, + [GOV_COMPANY_ID], + ); + await db( + `UPDATE freight.bookings SET status = 'CANCELLED', train_schedule_id = NULL, + scheduling_status = 'NOT_SCHEDULED', deleted_at = now() + WHERE company_id = $1 AND deleted_at IS NULL`, + [GOV_COMPANY_ID], + ); +} + +/** + * A priority band, created and dropped by the test that needs it. The rule + * engine scores each booking additively from the ACTIVE bands, so a band left + * behind would re-rank every later file's pool. + */ +export async function createPriorityConfig(body: { + type: "WAGON" | "CURRENCY" | "CUSTOMS"; + label: string; + currency?: "ETB" | "USD"; + minWagonCount: number; + maxWagonCount: number; + scorePoints: number; +}): Promise { + // Through the API, not SQL: bands must be contiguous from 1 per type + // (assertNoRangeCollision) and points are capped at 50 — a hand-inserted row + // could violate both and score a booking the product never would. + const res = await apiOk(superAdmin, "post", "/api/priority-configs", { + ...body, + isActive: true, + }); + const id = (res.body?.data?.id ?? res.body?.id) as string | undefined; + if (!id) throw new Error(`priority config not created: ${JSON.stringify(res.body)}`); + return id; +} + +export const dropPriorityConfig = (id: string) => + api(superAdmin, "delete", `/api/priority-configs/${id}`); + +export async function bookingRow(bookingId: string): Promise { + const [row] = await db( + `SELECT b.id, b.reference, b.status, b.scheduling_status, b.train_schedule_id, + b.payment_deadline, b.contract_id, b.is_split, b.pre_split_quantities, + b.wagons_required, b.cargo_total_weight_vgm, b.priority_score, + b.is_government + FROM freight.bookings b WHERE b.id = $1`, + [bookingId], + ); + return row; +} diff --git a/integration/src/g1-s1-expiry-promotes-waitlist.it.ts b/integration/src/g1-s1-expiry-promotes-waitlist.it.ts new file mode 100644 index 000000000..428d2128e --- /dev/null +++ b/integration/src/g1-s1-expiry-promotes-waitlist.it.ts @@ -0,0 +1,181 @@ +/** + * GROUP 1 · S1 — a no-pay expiry frees exactly the space the waiting list needs. + * + * A 3×40ft = 3 wagons + * B 20×20ft + 10×40ft = 20 wagons + * C 30×40ft = 30 wagons + * ───────── + * 53 = the whole BUILT train → all three reserved + * D 6×20ft = 3 wagons → no room → WAITING LIST + * + * B and C pay through the real gateway. A never does: its deadline passes, A + * EXPIRES, and its 3 wagons return to the day's pool. The top-up pass then + * promotes D — an exact fit — and D pays. + * + * Final consist: B 20 + C 30 + D 3 = 53/53, FULL. + * A is recoverable: its contract is untouched, so it can rebook a later day + * with no re-approval. + * + * The 53 slots are the BUILT train's coupled consist (seed-g1-train.sql), not a + * locomotive-length figure — see {@link createBuiltTrainSchedule}. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + G1_WAGONS, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + containerWagons, + createBuiltTrainSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectContractStillBookable, + extendPayWindow, + forceReservationExpiry, + forceWindowOpen, + linkedBookings, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, + setPriority, +} from "./flows"; + +const DEPARTURE = departureAt(50); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +/** Booking order is also PRIORITY order: A must be INSIDE the batch, because + * its expiry is what frees the space D needs. */ +const SHAPES = { + A: { twenty: 0, forty: 3, wagons: 3 }, + B: { twenty: 20, forty: 10, wagons: 20 }, + C: { twenty: 0, forty: 30, wagons: 30 }, + D: { twenty: 6, forty: 0, wagons: 3 }, +} as const; +const ORDER = ["A", "B", "C", "D"] as const; +const IN_BATCH = ["A", "B", "C"] as const; + +describe("g1 s1: expiry frees exactly the waiting list's space", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 20_000; + for (const [i, suffix] of ORDER.entries()) { + const shape = SHAPES[suffix]; + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: shape.twenty, + forty: shape.forty, + scheduledDate: BOOKING_DAY, + }), + ); + isoSeed += shape.twenty + shape.forty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + afterAll(closeDb); + + it("the wagon math is exactly one trainload, and D fits exactly A's share", () => { + for (const suffix of ORDER) { + expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe( + SHAPES[suffix].wagons, + ); + } + const booked = IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0); + expect(booked, "A+B+C fill the train exactly").toBe(G1_WAGONS); + expect(SHAPES.D.wagons, "D fits exactly the space A frees").toBe(SHAPES.A.wagons); + }); + + it("the batch reserves A, B and C; D holds a place in line rather than being rejected", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const suffix of IN_BATCH) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + expect( + (await bookingRow(booking.get(suffix)!)).payment_deadline, + `${suffix} got a pay window`, + ).toBeTruthy(); + } + const d = await bookingRow(booking.get("D")!); + expect(d.status, "D waitlisted").toBe("FULLY_EXECUTED"); + expect(d.train_schedule_id, "D holds no seat").toBeNull(); + }); + + it("B and C pay inside the window; A never does and EXPIRES, freeing 3 wagons", async () => { + // A is deliberately left out: the next assertion is about its expiry. + await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]); + await payViaGateway(booking.get("B")!); + await pollAllocations(booking.get("B")!, SHAPES.B.wagons); + await payViaGateway(booking.get("C")!); + await pollAllocations(booking.get("C")!, SHAPES.C.wagons); + + await forceReservationExpiry(booking.get("A")!); + expect((await bookingRow(booking.get("A")!)).status, "A expired unpaid").toBe("EXPIRED"); + }); + + it("the freed 3 wagons promote D — an exact fit — and D pays", async () => { + // fillFromWaitingList runs on the tick that follows the expiry; no second + // staff action is needed. + const promoted = await pollBookingStatus( + booking.get("D")!, + ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], + 40, + ); + expect(promoted.status, "D promoted off the waiting list").not.toBe("FULLY_EXECUTED"); + expect((await bookingRow(booking.get("D")!)).payment_deadline, "D got a pay window").toBeTruthy(); + + await extendPayWindow(scheduleId, [booking.get("D")!]); + await payViaGateway(booking.get("D")!); + await pollAllocations(booking.get("D")!, SHAPES.D.wagons); + }); + + it("the train departs FULL at 53/53 — B 20 + C 30 + D 3, and A holds no seat", async () => { + await endPaymentPhase(scheduleId); + await pollWindow( + scheduleId, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL + DONE", + ); + expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS); + expect(await linkedBookings(scheduleId), "3 bookings linked").toBe(3); + + for (const suffix of ["B", "C", "D"] as const) { + expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID"); + } + const a = await bookingRow(booking.get("A")!); + expect(a.status, "A does not ride").toBe("EXPIRED"); + expect(a.train_schedule_id, "A holds no seat").toBeNull(); + }); + + it("A is recoverable — its contract needs no re-approval to rebook a later day", async () => { + await expectContractStillBookable(booking.get("A")!); + }); +}); diff --git a/integration/src/g1-s2-exact-fill.it.ts b/integration/src/g1-s2-exact-fill.it.ts new file mode 100644 index 000000000..d13a1d3b6 --- /dev/null +++ b/integration/src/g1-s2-exact-fill.it.ts @@ -0,0 +1,142 @@ +/** + * GROUP 1 · S2 — four bookings pay and fill the train to the slot. + * + * A 3×40ft = 3 wagons · 3 containers + * B 20×20ft + 10×40ft = 20 wagons · 30 containers + * C 25×40ft = 25 wagons · 25 containers + * D 10×20ft = 5 wagons · 10 containers + * ───────── + * 53/53 → FULL + * + * Everyone is selected, everyone pays, nothing splits and nobody waits. What + * this really guards is the ALLOCATION rather than the arithmetic: the 53 + * wagons carry 68 containers and every one must land on exactly one slot with + * its number on it. A booking that took wagons but never mapped its units would + * still read 53/53 on the board — hence the per-container assertion at the end. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + G1_WAGONS, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + containerWagons, + createBuiltTrainSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectContainersPlaced, + expectNoPartialOffer, + extendPayWindow, + forceWindowOpen, + linkedBookings, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(51); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +const SHAPES = { + A: { twenty: 0, forty: 3, wagons: 3, containers: 3 }, + B: { twenty: 20, forty: 10, wagons: 20, containers: 30 }, + C: { twenty: 0, forty: 25, wagons: 25, containers: 25 }, + D: { twenty: 10, forty: 0, wagons: 5, containers: 10 }, +} as const; +const ORDER = ["A", "B", "C", "D"] as const; +const TOTAL_CONTAINERS = ORDER.reduce((sum, s) => sum + SHAPES[s].containers, 0); // 68 + +describe("g1 s2: four bookings pay and fill the train exactly", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 21_000; + for (const suffix of ORDER) { + const shape = SHAPES[suffix]; + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: shape.twenty, + forty: shape.forty, + scheduledDate: BOOKING_DAY, + }), + ); + isoSeed += shape.containers; + } + }, 1_800_000); + + afterAll(closeDb); + + it("the four bookings add up to exactly one trainload", () => { + for (const suffix of ORDER) { + expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe( + SHAPES[suffix].wagons, + ); + } + expect( + ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0), + "A+B+C+D fill the train exactly", + ).toBe(G1_WAGONS); + }); + + it("the batch reserves all four whole — an exact fit offers nobody a split", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ORDER) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + }); + + it("all four pay through the gateway and are allocated onto the train", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + for (const suffix of ORDER) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + }); + + it("the train is FULL at 53/53 and every one of the 68 containers has a slot", async () => { + await endPaymentPhase(scheduleId); + await pollWindow( + scheduleId, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL + DONE", + ); + expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS); + expect(await linkedBookings(scheduleId), "4 bookings linked").toBe(4); + for (const suffix of ORDER) { + expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID"); + } + + // Allocation is per CONTAINER, not just per wagon: 53 filled slots with + // only some units mapped would still read as a full train. + await expectContainersPlaced(scheduleId, TOTAL_CONTAINERS); + }); +}); diff --git a/integration/src/g1-s3-underfill-day-open.it.ts b/integration/src/g1-s3-underfill-day-open.it.ts new file mode 100644 index 000000000..93f3883ec --- /dev/null +++ b/integration/src/g1-s3-underfill-day-open.it.ts @@ -0,0 +1,141 @@ +/** + * GROUP 1 · S3 — an under-filled train keeps its day open. + * + * A 12×20ft = 6 wagons + * B 10×40ft = 10 wagons + * C 24×20ft = 12 wagons + * ───────── + * 28/53 → 25 slots still free + * + * Everyone pays, nobody splits, nobody waits. The assertion is the NEGATIVE + * one: the window must NOT be marked FULL, because the day has to stay on offer + * to customers who have not booked yet. A train that closed its day at 28/53 + * would silently refuse 25 wagons of business — so the claim is checked the way + * a customer experiences it, through the portal's own day-availability query. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + G1_WAGONS, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + containerWagons, + createBuiltTrainSchedule, + dayAvailability, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectNoPartialOffer, + extendPayWindow, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollCycleConcluded, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, +} from "./flows"; + +const DEPARTURE = departureAt(52); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +const SHAPES = { + A: { twenty: 12, forty: 0, wagons: 6 }, + B: { twenty: 0, forty: 10, wagons: 10 }, + C: { twenty: 24, forty: 0, wagons: 12 }, +} as const; +const ORDER = ["A", "B", "C"] as const; +const BOOKED_WAGONS = ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0); // 28 +const FREE_WAGONS = G1_WAGONS - BOOKED_WAGONS; // 25 + +describe("g1 s3: an under-filled train keeps its day open", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 22_000; + for (const suffix of ORDER) { + const shape = SHAPES[suffix]; + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: shape.twenty, + forty: shape.forty, + scheduledDate: BOOKING_DAY, + }), + ); + isoSeed += shape.twenty + shape.forty; + } + }, 1_800_000); + + afterAll(closeDb); + + it("the three bookings leave 25 of the 53 slots free", () => { + for (const suffix of ORDER) { + expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe( + SHAPES[suffix].wagons, + ); + } + expect(BOOKED_WAGONS, "A+B+C = 28 wagons").toBe(28); + expect(FREE_WAGONS, "25 slots unused").toBe(25); + }); + + it("the batch reserves all three whole — with room to spare nobody is offered a split", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ORDER) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + }); + + it("all three pay — 28 of 53 wagons used and the window is NOT marked FULL", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + for (const suffix of ORDER) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + await endPaymentPhase(scheduleId); + await pollCycleConcluded(scheduleId); + + expect(await allocatedWagons(scheduleId), "28 wagons allocated").toBe(BOOKED_WAGONS); + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "window not FULL at 28/53", + ).not.toBe("FULL"); + for (const suffix of ORDER) { + expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID"); + } + }); + + it("the day is still on offer to customers, with 25 free wagons", async () => { + // The customer-facing consequence, asked the way the portal asks it. A + // train that under-filled but stopped offering its day is the actual bug + // this scenario guards, and `freeWagons` is where it would show. + const day = await dayAvailability(booking.get("A")!, BOOKING_DAY); + expect(day.trainsForDay, "the day still runs a train").toBe(true); + expect(Number(day.freeWagons), "25 wagons still on offer").toBe(FREE_WAGONS); + }); +}); diff --git a/integration/src/g1-s4-split-closes-gap.it.ts b/integration/src/g1-s4-split-closes-gap.it.ts new file mode 100644 index 000000000..0f934ea59 --- /dev/null +++ b/integration/src/g1-s4-split-closes-gap.it.ts @@ -0,0 +1,172 @@ +/** + * GROUP 1 · S4 — an over-subscribed day closes its last gap with a split. + * + * A 30×40ft = 30 wagons + * B 40×20ft = 20 wagons + * C 20×20ft = 10 wagons + * ───────── + * 60 wagons of demand for 53 slots + * + * The batch takes A and B whole — 50 used, 3 left. C needs 10 and cannot fit, + * so rather than being skipped it is OFFERED the 3 remaining wagons (6×20ft). + * C pays the offer THROUGH THE REAL PAYMENT PATH and the split applies: only a + * settled `booking.invoice.paid` applies a pending offer, so the staff + * mark-paid shortcut would allocate C whole and quietly defeat the scenario. + * + * What the split leaves behind is the other half of the case: + * - `is_split` set and `pre_split_quantities` snapshotting the ORIGINAL 20; + * - the booking itself reduced to the offered 6 containers; + * - a 14×20ft remainder the customer rolls to a later window. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + G1_WAGONS, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + containerCount, + containerWagons, + createBuiltTrainSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectNoPartialOffer, + extendPayWindow, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollPartialOffer, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + seedTenantContracts, + setPriority, +} from "./flows"; + +const DEPARTURE = departureAt(53); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +const SHAPES = { + A: { twenty: 0, forty: 30, wagons: 30 }, + B: { twenty: 40, forty: 0, wagons: 20 }, + C: { twenty: 20, forty: 0, wagons: 10 }, +} as const; +const ORDER = ["A", "B", "C"] as const; +/** A + B take 50 of 53; the gap is what C is offered. */ +const GAP_WAGONS = G1_WAGONS - SHAPES.A.wagons - SHAPES.B.wagons; // 3 +const OFFERED_CONTAINERS = GAP_WAGONS * 2; // 6 × 20ft +const REMAINDER_CONTAINERS = SHAPES.C.twenty - OFFERED_CONTAINERS; // 14 + +describe("g1 s4: a split closes the last 3-wagon gap", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 23_000; + for (const [i, suffix] of ORDER.entries()) { + const shape = SHAPES[suffix]; + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: shape.twenty, + forty: shape.forty, + scheduledDate: BOOKING_DAY, + }), + ); + isoSeed += shape.twenty + shape.forty; + // Priority decides who gets a whole seat and who gets the offer. + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + afterAll(closeDb); + + it("demand exceeds the train by 7 wagons, leaving a 3-wagon gap after A and B", () => { + for (const suffix of ORDER) { + expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe( + SHAPES[suffix].wagons, + ); + } + expect( + ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0), + "60 wagons of demand", + ).toBe(60); + expect(GAP_WAGONS, "3-wagon gap after A+B").toBe(3); + expect(SHAPES.C.wagons, "C cannot fit whole").toBeGreaterThan(GAP_WAGONS); + }); + + it("the batch takes A and B whole and offers C exactly the 3 remaining wagons", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const suffix of ["A", "B"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + const offer = await pollPartialOffer(booking.get("C")!); + expect(Number(offer.offered_wagons), "offer sized to the gap").toBe(GAP_WAGONS); + }); + + it("A and B pay whole; C settles its partial and the split applies", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + await payViaGateway(booking.get("A")!); + await pollAllocations(booking.get("A")!, SHAPES.A.wagons); + await payViaGateway(booking.get("B")!); + await pollAllocations(booking.get("B")!, SHAPES.B.wagons); + + await payViaGateway(booking.get("C")!); + await pollAllocations(booking.get("C")!, GAP_WAGONS); + }); + + it("C is flagged split, snapshotted at 20×20ft, and reduced to the offered 6", async () => { + const c = await bookingRow(booking.get("C")!); + expect(c.is_split, "C is split").toBe(true); + + const snapshot = c.pre_split_quantities as { bySize?: Record } | null; + expect(snapshot, "pre-split snapshot kept").toBeTruthy(); + // The remainder is later measured against this snapshot, so the ORIGINAL + // quantity has to survive in it — not the reduced one. + expect( + Number(snapshot?.bySize?.["20FT"] ?? snapshot?.bySize?.["20ft"]), + "snapshot holds the original 20 × 20ft", + ).toBe(SHAPES.C.twenty); + + expect(await containerCount(booking.get("C")!), `C shrank to ${OFFERED_CONTAINERS} boxes`).toBe( + OFFERED_CONTAINERS, + ); + }); + + it("the train is FULL at 53/53 and C's 14-container remainder is outstanding", async () => { + await endPaymentPhase(scheduleId); + await pollWindow( + scheduleId, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL + DONE", + ); + expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS); + // 20 booked − 6 shipped = 14 still owed; the engine holds the customer to + // rebooking exactly that (asserted for bulk in bulk-import-split-promote). + expect(REMAINDER_CONTAINERS, "14 × 20ft outstanding").toBe(14); + }); +}); diff --git a/integration/src/g1-s5-cascading-expiry.it.ts b/integration/src/g1-s5-cascading-expiry.it.ts new file mode 100644 index 000000000..120e6115b --- /dev/null +++ b/integration/src/g1-s5-cascading-expiry.it.ts @@ -0,0 +1,190 @@ +/** + * GROUP 1 · S5 — one expiry cascades into a second promotion. + * + * In the batch: A 10w + B 20w + C 23w = 53/53 + * Waiting: D 8w, E 5w (priority order D before E) + * + * A never pays and EXPIRES → 10 wagons freed. ONE settle then serves BOTH + * waiting bookings in the same pass: D is reserved whole (8w) and E, which no + * longer fits in the 2 wagons left, is OFFERED a partial of exactly those 2. + * That is the assertion the scenario exists for — fillFromWaitingList loops + * until a pass places nothing, so a single-pass top-up would leave E untouched + * until the next window cycle. + * + * D then expires too, freeing 8 more wagons — and E's offer is NOT resized. + * FINDING (the same one bulk-b1 pins): a refill never supersedes an open + * partial offer, so E pays its stale 2-wagon offer and ships 2 of its 5 wagons + * while 8 sit idle. The train settles at B 20 + C 23 + E 2 = 45/53. + * + * Every expiry must also leave an audit trail — a terminal EXPIRED booking with + * its invoice closed out, never a silent disappearance. An open invoice on an + * expired seat is money the customer could still pay for a train they are no + * longer on. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + G1_WAGONS, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + containerWagons, + createBuiltTrainSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + extendPayWindow, + forceReservationExpiry, + forceWindowOpen, + livePayableInvoices, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollCycleConcluded, + pollPartialOffer, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, + setPriority, +} from "./flows"; + +const DEPARTURE = departureAt(54); +const BOOKING_DAY = eatDayStr(DEPARTURE); +const STAMP = String(Date.now()); + +const SHAPES = { + A: { twenty: 20, forty: 0, wagons: 10 }, + B: { twenty: 40, forty: 0, wagons: 20 }, + C: { twenty: 0, forty: 23, wagons: 23 }, + D: { twenty: 16, forty: 0, wagons: 8 }, + E: { twenty: 10, forty: 0, wagons: 5 }, +} as const; +const ORDER = ["A", "B", "C", "D", "E"] as const; +const IN_BATCH = ["A", "B", "C"] as const; +const WAITING = ["D", "E"] as const; +/** What A's expiry leaves loose once D takes its 8 — E's offer is sized to it. */ +const E_OFFER = SHAPES.A.wagons - SHAPES.D.wagons; // 2 +const RIDING = SHAPES.B.wagons + SHAPES.C.wagons + E_OFFER; // 45 + +describe("g1 s5: expiry cascades into a second promotion", () => { + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 24_000; + for (const [i, suffix] of ORDER.entries()) { + const shape = SHAPES[suffix]; + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: shape.twenty, + forty: shape.forty, + scheduledDate: BOOKING_DAY, + }), + ); + isoSeed += shape.twenty + shape.forty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + afterAll(closeDb); + + it("A+B+C fill the train; D and E queue behind them, each fitting the hole above", () => { + for (const suffix of ORDER) { + expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe( + SHAPES[suffix].wagons, + ); + } + expect( + IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0), + "A+B+C fill the train exactly", + ).toBe(G1_WAGONS); + expect(SHAPES.D.wagons, "D fits inside A's 10").toBeLessThan(SHAPES.A.wagons); + expect(SHAPES.E.wagons, "E fits inside D's 8").toBeLessThan(SHAPES.D.wagons); + }); + + it("the batch reserves A, B and C; D and E wait in priority order", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of IN_BATCH) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + for (const suffix of WAITING) { + const row = await bookingRow(booking.get(suffix)!); + expect(row.status, `${suffix} waitlisted`).toBe("FULLY_EXECUTED"); + expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull(); + } + }); + + it("B and C pay; A expires and ONE pass serves both waiting bookings", async () => { + await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]); + await payViaGateway(booking.get("B")!); + await pollAllocations(booking.get("B")!, SHAPES.B.wagons); + await payViaGateway(booking.get("C")!); + await pollAllocations(booking.get("C")!, SHAPES.C.wagons); + + await forceReservationExpiry(booking.get("A")!); + // D fits A's hole whole… + await pollBookingStatus(booking.get("D")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40); + // …and the same pass keeps going: E no longer fits in the 2 wagons left, so + // it is offered exactly those rather than being left for the next cycle. + const offer = await pollPartialOffer(booking.get("E")!); + expect(Number(offer.offered_wagons), "E offered the 2 loose wagons").toBe(E_OFFER); + }); + + it("D expires too — but E's stale 2-wagon offer is never resized", async () => { + await forceReservationExpiry(booking.get("D")!); + // FINDING (bulk-b1 pins the same gap): the refill re-selects a booking that + // already holds an OFFER without re-sizing it, so the 8 wagons D just freed + // stay unsold and E ships 2 of the 5 it asked for. Assert what the engine + // really does, so a fix to the offer path fails loudly here. + await pollBookingStatus(booking.get("E")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40); + expect((await bookingRow(booking.get("E")!)).payment_deadline, "E got a pay window").toBeTruthy(); + + await extendPayWindow(scheduleId, [booking.get("E")!]); + await payViaGateway(booking.get("E")!); + await pollAllocations(booking.get("E")!, E_OFFER); + expect((await bookingRow(booking.get("E")!)).is_split, "E rode split, 2 of 5").toBe(true); + }); + + it("both expiries are terminal and auditable — nothing vanished silently", async () => { + for (const suffix of ["A", "D"] as const) { + const row = await bookingRow(booking.get(suffix)!); + expect(row.status, `${suffix} terminal EXPIRED`).toBe("EXPIRED"); + expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull(); + expect( + await livePayableInvoices(booking.get(suffix)!), + `${suffix} has no live payable invoice`, + ).toBe(0); + } + }); + + it("the train settles at 45/53 — eight wagons unsold, short of FULL", async () => { + await endPaymentPhase(scheduleId); + await pollCycleConcluded(scheduleId); + expect(await allocatedWagons(scheduleId), "45 wagons allocated").toBe(RIDING); + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "window not FULL at 45/53", + ).not.toBe("FULL"); + }); +}); diff --git a/integration/src/g1-s6-s8-offers-government-tiers.it.ts b/integration/src/g1-s6-s8-offers-government-tiers.it.ts new file mode 100644 index 000000000..375ca153c --- /dev/null +++ b/integration/src/g1-s6-s8-offers-government-tiers.it.ts @@ -0,0 +1,420 @@ +/** + * GROUP 1 · S6–S8 — who gets the last wagons on the 53-wagon built train. + * + * S6 a split offer nobody takes: the offer lapses, the booking expires + * WHOLE, and the wagons it was offered go unsold. + * S7 a government booking jumps the queue — by PREEMPTION, not by ranking: + * it displaces the lowest-priority commercial reservation and rides + * unpaid, carrying the +50 000 bonus. + * S8 commercial priority tiers decide who is offered the remainder: + * USD payer > customs service > plain, with no per-booking priority set. + * + * TWO NOTES ON HOW THE PRODUCT REALLY WORKS + * + * S7 — the +50 000 bonus (GOVERNMENT_PRIORITY_BONUS) keys off + * `bookings.is_government`, not a government-institution lookup, and + * government does not merely outrank: `preemptForGovernment` EXPIRES the + * lowest-priority commercial booking whose leg overlaps and allocates in its + * place. Government bookings are created with POST /api/bookings against a + * kind='government' company and promoted with /government-expedite — never + * through the contract wizard. + * + * FINDING — preemption cannot reach a train whose window already reads FULL: + * `isFillable` rejects FULL outright, before any budget or victim is + * considered, and `refreshWindowStatus` re-derives FULL from live capacity, so + * a genuinely full train stays skipped. The scenario therefore books 52 of 53 + * slots: committed, one slot short, which is the closest reachable state to + * "a full train" and still exercises the displacement. + * + * S8 — the retired USD_PAYER / RAIL_AND_FORWARDING priority RULES are gone + * (ReplacePriorityRulesWithPriorityConfigs). The live model is + * `priority_configs`, typed WAGON | CURRENCY | CUSTOMS and scored by + * wagon-count band. The scenario's intent — tiered ordering, lowest tier gets + * the split — is preserved against that mechanism. The CUSTOMS band only + * applies when the booking's SERVICE TYPE bundles customs, which is why S8's + * customs tenant is sold RAIL_CUSTOMS (seed-customs-service-type.sql). + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway } from "./client"; +import { + G1_WAGONS, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + containerCount, + containerWagons, + createBuiltTrainSchedule, + createGovernmentBooking, + createPriorityConfig, + departureAt, + dropPriorityConfig, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectNoPartialOffer, + extendPayWindow, + forceOfferLapse, + forceWindowOpen, + governmentExpedite, + linkedBookings, + livePayableInvoices, + payViaGateway, + pinToSchedule, + pollAllocations, + pollBookingStatus, + pollCycleConcluded, + pollPartialOffer, + pollWindow, + releaseGovernmentBookings, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, + setPriority, + triggerBatchRun, +} from "./flows"; + +const STAMP = String(Date.now()); + +// ─────────────────────────────────────────────────────────────────────────── +// S6 — a split offer nobody takes +// ─────────────────────────────────────────────────────────────────────────── + +describe("g1 s6: an ignored split offer expires the booking whole", () => { + const DEPARTURE = departureAt(55); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + SA: { twenty: 0, forty: 30, wagons: 30 }, + SB: { twenty: 40, forty: 0, wagons: 20 }, + SC: { twenty: 20, forty: 0, wagons: 10 }, + } as const; + const ORDER = ["SA", "SB", "SC"] as const; + const GAP = G1_WAGONS - SHAPES.SA.wagons - SHAPES.SB.wagons; // 3 + const RIDING = SHAPES.SA.wagons + SHAPES.SB.wagons; // 50 + + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 25_000; + for (const [i, suffix] of ORDER.entries()) { + const shape = SHAPES[suffix]; + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: shape.twenty, + forty: shape.forty, + scheduledDate: BOOKING_DAY, + }), + ); + isoSeed += shape.twenty + shape.forty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("SA and SB take 50 wagons; SC is offered the last 3", async () => { + expect(containerWagons(SHAPES.SC.twenty, 0), "SC needs 10 wagons").toBe(SHAPES.SC.wagons); + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const suffix of ["SA", "SB"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + const offer = await pollPartialOffer(booking.get("SC")!); + expect(Number(offer.offered_wagons), "SC offered the 3-wagon gap").toBe(GAP); + }); + + it("SC ignores the offer for the whole window — it EXPIRES whole", async () => { + await extendPayWindow(scheduleId, [booking.get("SA")!, booking.get("SB")!]); + await payViaGateway(booking.get("SA")!); + await pollAllocations(booking.get("SA")!, SHAPES.SA.wagons); + await payViaGateway(booking.get("SB")!); + await pollAllocations(booking.get("SB")!, SHAPES.SB.wagons); + + // The offer dies with the booking's pay deadline; the tick settles it. + await forceOfferLapse(booking.get("SC")!); + + // "Whole" is the load-bearing word: an ignored PARTIAL must not leave the + // booking silently reduced to the 3 wagons it was offered — the customer + // still owns all 20 containers and can rebook them intact. + const sc = await bookingRow(booking.get("SC")!); + expect(sc.is_split, "SC was never split").not.toBe(true); + expect(await containerCount(booking.get("SC")!), "SC's 20 containers intact").toBe( + SHAPES.SC.twenty, + ); + }); + + it("the train departs NOT FULL at 50/53 — the 3 offered wagons went unsold", async () => { + await endPaymentPhase(scheduleId); + await pollCycleConcluded(scheduleId); + expect(await allocatedWagons(scheduleId), "50 wagons allocated").toBe(RIDING); + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "window not FULL at 50/53", + ).not.toBe("FULL"); + expect(GAP, "3 wagons wasted").toBe(3); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S7 — government preempts the lowest-priority commercial reservation +// ─────────────────────────────────────────────────────────────────────────── + +describe("g1 s7: a government booking preempts commercial", () => { + const DEPARTURE = departureAt(56); + const BOOKING_DAY = eatDayStr(DEPARTURE); + /** GA outranks GB, so GB is the one preemption must take. */ + const SHAPES = { + GA: { forty: 25, wagons: 25 }, + GB: { forty: 27, wagons: 27 }, + } as const; + const ORDER = ["GA", "GB"] as const; + /** More than the single free slot: the government booking cannot fit as-is. */ + const GOV_WAGONS = 15; + + const booking = new Map(); + let scheduleId: string; + let govBookingId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await releaseGovernmentBookings(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 26_000; + for (const [i, suffix] of ORDER.entries()) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + }), + ); + isoSeed += SHAPES[suffix].forty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("GA pays and GB holds a reservation — 52 of 53 slots committed", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ORDER) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + + await extendPayWindow(scheduleId, [booking.get("GA")!]); + await payViaGateway(booking.get("GA")!); + await pollAllocations(booking.get("GA")!, SHAPES.GA.wagons); + + // GB stays UNPAID on purpose — it is the reservation the government + // booking must displace — but its hold is widened so it does not lapse on + // its own while the government booking is being created. + await extendPayWindow(scheduleId, [booking.get("GB")!]); + expect((await bookingRow(booking.get("GB")!)).status, "GB still reserved").toMatch( + /SELECTED_FOR_BATCH|AWAITING_PAYMENT/, + ); + expect(SHAPES.GA.wagons + SHAPES.GB.wagons, "52 of 53 committed").toBe(G1_WAGONS - 1); + }); + + it("a government booking is created and expedited — PAID without paying", async () => { + govBookingId = await createGovernmentBooking({ forty: GOV_WAGONS }); + // Idempotent: create() already expedites, the endpoint is the retry path. + await governmentExpedite(govBookingId); + + const gov = await bookingRow(govBookingId); + expect(gov.status, "PAID after expedite").toBe("PAID"); + expect(gov.is_government, "flagged government").toBe(true); + }); + + it("it carries the +50 000 bonus, far above any commercial score", async () => { + const govScore = Number((await bookingRow(govBookingId)).priority_score); + expect(govScore, "government bonus applied").toBeGreaterThanOrEqual(50_000); + expect( + Number((await bookingRow(booking.get("GA")!)).priority_score), + "top commercial still far below government", + ).toBeLessThan(govScore); + }); + + it("the fill displaces GB — the lower-priority reservation — and GA is untouched", async () => { + // Staff pin, then run the batch: fillSchedule's pool is keyed on + // `booking.train_schedule_id`, and the customer-facing pin is unavailable + // because the window closed at doc review. + await pinToSchedule(govBookingId, scheduleId); + await triggerBatchRun(scheduleId); + + await pollBookingStatus(booking.get("GB")!, "EXPIRED", 30); + const gb = await bookingRow(booking.get("GB")!); + expect(gb.scheduling_status, "GB back to ELIGIBLE").toBe("ELIGIBLE"); + expect(gb.payment_deadline, "GB pay window cleared").toBeNull(); + expect(await livePayableInvoices(booking.get("GB")!), "GB invoice closed out").toBe(0); + + const ga = await bookingRow(booking.get("GA")!); + expect(ga.status, "GA survives untouched").toBe("PAID"); + expect(ga.train_schedule_id, "GA still on this train").toBe(scheduleId); + }); + + it("the government booking rides on the freed wagons — allocated, never invoiced", async () => { + await pollAllocations(govBookingId, GOV_WAGONS); + const gov = await bookingRow(govBookingId); + expect(gov.scheduling_status, "government SCHEDULED").toBe("SCHEDULED"); + expect(await livePayableInvoices(govBookingId), "government rides unpaid").toBe(0); + expect(await linkedBookings(scheduleId), "GA + government hold the seats").toBe(2); + expect(await allocatedWagons(scheduleId), "25 commercial + 15 government").toBe( + SHAPES.GA.wagons + GOV_WAGONS, + ); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S8 — commercial priority tiers order the batch +// ─────────────────────────────────────────────────────────────────────────── + +describe("g1 s8: priority tiers order the batch", () => { + const DEPARTURE = departureAt(57); + const BOOKING_DAY = eatDayStr(DEPARTURE); + /** Three equal bookings — only the TIER differs, so ordering is the only + * thing that can decide who is left with the remainder. */ + const SHAPES = { + PA: { forty: 20, wagons: 20, tier: "USD payer" }, + PB: { forty: 20, wagons: 20, tier: "customs service" }, + PC: { forty: 20, wagons: 20, tier: "plain" }, + } as const; + const ORDER = ["PA", "PB", "PC"] as const; + /** 60 wagons of demand for 53 slots → the third gets a 13-wagon offer. */ + const GAP = G1_WAGONS - SHAPES.PA.wagons - SHAPES.PB.wagons; // 13 + + const booking = new Map(); + let scheduleId: string; + let usdConfigId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + // The USD tier. The CUSTOMS bands (7 / 15 points) ship with the corridor + // fixture, so only the currency band has to be added — and it is dropped in + // afterAll, because an active band re-ranks every later file's pool. + usdConfigId = await createPriorityConfig({ + type: "CURRENCY", + label: `IT USD payer ${STAMP.slice(-5)}`, + currency: "USD", + minWagonCount: 1, + maxWagonCount: 53, + scorePoints: 35, + }); + + const contracts = await seedTenantContracts(STAMP, [ + { suffix: "PA", freight: "CONTAINER" as const, currency: "USD" as const }, + { + suffix: "PB", + freight: "CONTAINER" as const, + customs: true, + serviceTypeCode: "RAIL_CUSTOMS", + }, + { suffix: "PC", freight: "CONTAINER" as const }, + ]); + scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 27_000; + for (const suffix of ORDER) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + forty: SHAPES[suffix].forty, + scheduledDate: BOOKING_DAY, + customs: suffix === "PB", + }), + ); + isoSeed += SHAPES[suffix].forty; + } + }, 1_800_000); + + afterAll(async () => { + await dropPriorityConfig(usdConfigId); + await closeDb(); + }); + + it("the engine scores USD above customs above plain — no manual priority set", async () => { + // Deliberately no setPriority anywhere in this file: the point is that the + // rule engine's own bands produce the order. Pinning the scores by hand + // would test setPriority, not the tiers. + const scores = new Map(); + for (const suffix of ORDER) { + scores.set(suffix, Number((await bookingRow(booking.get(suffix)!)).priority_score)); + } + expect(scores.get("PA")!, "USD tier outranks the customs tier").toBeGreaterThan( + scores.get("PB")!, + ); + expect(scores.get("PB")!, "customs tier outranks plain").toBeGreaterThan(scores.get("PC")!); + }); + + it("the two top tiers board whole; the lowest is offered the 13-wagon remainder", async () => { + expect(GAP, "13-wagon remainder after the top two").toBe(13); + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const suffix of ["PA", "PB"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + const offer = await pollPartialOffer(booking.get("PC")!); + expect(Number(offer.offered_wagons), "PC offered the exact remainder").toBe(GAP); + }); + + it("all three settle — PC ships 13 of its 20 wagons and the train is FULL", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + await payViaGateway(booking.get("PA")!); + await pollAllocations(booking.get("PA")!, SHAPES.PA.wagons); + await payViaGateway(booking.get("PB")!); + await pollAllocations(booking.get("PB")!, SHAPES.PB.wagons); + await payViaGateway(booking.get("PC")!); + await pollAllocations(booking.get("PC")!, GAP); + + expect((await bookingRow(booking.get("PC")!)).is_split, "PC is the split one").toBe(true); + expect(await containerCount(booking.get("PC")!), "PC shrank to 13 containers").toBe(GAP); + + await endPaymentPhase(scheduleId); + await pollWindow( + scheduleId, + (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE", + "FULL + DONE", + ); + expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS); + }); +}); diff --git a/integration/src/global-setup.ts b/integration/src/global-setup.ts new file mode 100644 index 000000000..2805a7b4e --- /dev/null +++ b/integration/src/global-setup.ts @@ -0,0 +1,70 @@ +/** + * Runs once before any spec: wait for both APIs, then seed. + * + * Seeds are the Cypress suite's fixtures, reused verbatim (they are idempotent + * `insert … where not exists`), plus one of our own for the second tenant: + * seed-users.sql → seed-company.sql (order matters; company needs the users) + * seed-import-corridor.sql (yards, locos, wagons, rates, distances) + * seed-bulk-items.sql (PER_ITEM break-bulk cargo types) + * seed-g1-train.sql (the 53-wagon BUILT container train) + * seed-government.sql (the kind='government' company) + * seed-company-b.sql (user2@gmail.com's company — this suite) + * seed-customs-service-type.sql (a service type that bundles customs) + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "pg"; + +const API = process.env.IT_API_URL ?? "http://localhost:3111"; +const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113"; +const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600"; +const DB_URL = + process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e"; + +const CYPRESS_FIXTURES = join(process.cwd(), "..", "e2e", "freight", "cypress", "fixtures"); +const OWN_FIXTURES = join(process.cwd(), "sql"); + +const SEEDS: Array<[dir: string, file: string]> = [ + [CYPRESS_FIXTURES, "seed-users.sql"], + [CYPRESS_FIXTURES, "seed-company.sql"], + [CYPRESS_FIXTURES, "seed-import-corridor.sql"], + [CYPRESS_FIXTURES, "seed-bulk-items.sql"], + [CYPRESS_FIXTURES, "seed-g1-train.sql"], + [CYPRESS_FIXTURES, "seed-government.sql"], + [OWN_FIXTURES, "seed-company-b.sql"], + [OWN_FIXTURES, "seed-customs-service-type.sql"], +]; + +async function waitFor(label: string, url: string, attempts = 60): Promise { + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(url); + if (res.ok) return; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error(`${label} never became healthy at ${url}`); +} + +export async function setup(): Promise { + await Promise.all([ + waitFor("freight-api", `${API}/api/health`), + waitFor("payment-api", `${PAYMENT_API}/health`), + waitFor("gateway-mock", `${GATEWAY}/__control/health`), + ]); + + const client = new Client({ connectionString: DB_URL }); + await client.connect(); + try { + for (const [dir, file] of SEEDS) { + await client.query(readFileSync(join(dir, file), "utf8")); + console.log(`it: seeded ${file}`); + } + } finally { + await client.end(); + } + + await fetch(`${GATEWAY}/__control/reset`, { method: "POST" }); +} diff --git a/integration/src/payment-failure.it.ts b/integration/src/payment-failure.it.ts new file mode 100644 index 000000000..17a8cd0be --- /dev/null +++ b/integration/src/payment-failure.it.ts @@ -0,0 +1,249 @@ +/** + * What happens when the bank misbehaves. Each test forces the gateway mock + * into a failure mode and asserts the platform's answer — the point being that + * NO failure may ever settle an invoice that was not paid, and no failure may + * lose a payment that was. + * + * Covered: provider down at initiate, hard decline, forged signature, replayed + * callback, silent settlement found only by the reconciliation sweep, and the + * unverifiable answer that must stop freight from expiring a paying customer. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, db, gateway, payment, poll, sleep } from "./client"; +import { + createImportSchedule, + currentInvoice, + departureAt, + ensureCorridorRoute, + releaseUnpaidHolds, + forceWindowOpen, + gatewayIntent, + invoiceForBooking, + payInvoice, + prepareBooking, + resetCorridorDay, + runBatch, + type ReadyBooking, +} from "./flows"; + +const DEPARTURE = departureAt(6); +const STAMP = String(Date.now()); + +/** Four independent bookings so one test's terminal state can't poison another. */ +const CASES = ["FAIL1", "FAIL2", "FAIL3", "FAIL4"] as const; +type CaseName = (typeof CASES)[number]; + +describe("payment failure and recovery", () => { + const bookings = new Map(); + const invoices = new Map(); + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + const schedule = await createImportSchedule({ departure: DEPARTURE }); + await forceWindowOpen(schedule.id, 45); + + let isoSeed = 100; + for (const suffix of CASES) { + bookings.set( + suffix, + await prepareBooking({ + suffix, + departure: DEPARTURE, + runStamp: STAMP, + isoSeed, + twenty: 2, + }), + ); + isoSeed += 2; + } + await runBatch(schedule.id); + for (const suffix of CASES) { + invoices.set(suffix, (await invoiceForBooking(bookings.get(suffix)!.bookingId)).id); + } + }, 600_000); + + afterAll(closeDb); + + it("leaves the invoice payable when the provider is unreachable", async () => { + const invoiceId = invoices.get("FAIL1")!; + await gateway.mode("cbe-birr", "fail"); + + const res = await payInvoice(invoiceId, { method: "CBE_BIRR" }); + expect(res.status).toBeGreaterThanOrEqual(400); + + // No phantom settlement, and the customer can retry. + const invoice = await currentInvoice(invoiceId); + expect(invoice.status).not.toBe("PAID"); + expect(invoice.paid_at).toBeNull(); + + await gateway.mode("cbe-birr", "ok"); + const retry = await payInvoice(invoiceId, { method: "CBE_BIRR" }); + expect(retry.status, JSON.stringify(retry.body)).toBeLessThanOrEqual(201); + }); + + it("keeps the invoice open on a declined payment", async () => { + const { bookingId } = bookings.get("FAIL2")!; + const invoiceId = invoices.get("FAIL2")!; + await gateway.mode("cbe-birr", "ok"); + expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201); + + const intent = await gatewayIntent(bookingId); + await gateway.webhook({ merchantOrderId: intent.merchant_order_id, status: "FAILED" }); + + const failed = await poll<{ status: string }>( + "intent FAILED", + `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, + [intent.id], + (row) => row?.status === "FAILED", + { attempts: 20, intervalMs: 1000 }, + ); + expect(failed.status).toBe("FAILED"); + + const invoice = await currentInvoice(invoiceId); + expect(invoice.status).not.toBe("PAID"); + }); + + it("ignores a forged signature — event recorded, money untouched", async () => { + const { bookingId } = bookings.get("FAIL3")!; + const invoiceId = invoices.get("FAIL3")!; + expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201); + + const intent = await gatewayIntent(bookingId); + const res = await gateway.webhook({ + merchantOrderId: intent.merchant_order_id, + signature: "bad", + }); + // Providers must still get a 2xx (Waafi times out at 5s and never retries). + expect(res.body.delivered).toBe(200); + + const event = await poll<{ signature_valid: boolean; processing_error: string | null }>( + "forged webhook recorded", + `SELECT signature_valid, processing_error FROM edr_payment.payment_webhook_event + WHERE merchant_order_id = $1 ORDER BY received_at DESC LIMIT 1`, + [intent.merchant_order_id], + (row) => !!row, + { attempts: 15, intervalMs: 1000 }, + ); + expect(event.signature_valid).toBe(false); + expect(event.processing_error).toBe("signature-invalid"); + + await sleep(3000); + const after = await db<{ status: string }>( + `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, + [intent.id], + ); + expect(after[0].status).not.toBe("SUCCEEDED"); + expect((await currentInvoice(invoiceId)).status).not.toBe("PAID"); + }); + + it("settles from the reconciliation sweep alone, with no callback at all", async () => { + const { bookingId } = bookings.get("FAIL4")!; + const invoiceId = invoices.get("FAIL4")!; + expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201); + + const intent = await gatewayIntent(bookingId); + // The customer pays at the bank, but the callback is lost in the network. + await gateway.settle(intent.merchant_order_id); + + // RECONCILE_STALE_AFTER_MS=5s, sweep every 30s — one sweep is enough. + const settled = await poll<{ status: string }>( + "intent settled by sweep", + `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, + [intent.id], + (row) => row?.status === "SUCCEEDED", + { attempts: 30, intervalMs: 3000 }, + ); + expect(settled.status).toBe("SUCCEEDED"); + + const invoice = await poll<{ status: string }>( + "invoice PAID via sweep", + `SELECT status FROM freight.invoices WHERE id = $1`, + [invoiceId], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + expect(invoice.status).toBe("PAID"); + + // No webhook was ever delivered for this one. + const [{ n }] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event + WHERE merchant_order_id = $1`, + [intent.merchant_order_id], + ); + expect(Number(n)).toBe(0); + }); + + it("reports `unverifiable` when the gateway cannot answer, so freight must not expire the hold", async () => { + // FAIL1 still has a live (unsettled) intent — FAIL2's is terminal, and a + // reference with nothing to verify legitimately answers "not paid". + const { bookingId } = bookings.get("FAIL1")!; + await gateway.mode("cbe-birr", "timeout"); + + const res = await payment("post", "/payments/reconcile", { + service: "FREIGHT", + referenceType: "SHIPMENT", + referenceId: bookingId, + }); + await gateway.mode("cbe-birr", "ok"); + + expect([200, 201]).toContain(res.status); + const body = res.body?.data ?? res.body; + expect(body.paid).toBe(false); + // An unknown answer must never read as "definitely unpaid" — that is what + // stops the batch engine from expiring a customer who actually paid. + expect(body.unverifiable).toBe(true); + }); + + it("captures late: a settlement after the intent expired still pays the invoice", async () => { + const { bookingId } = bookings.get("FAIL3")!; + const invoiceId = invoices.get("FAIL3")!; + const intent = await gatewayIntent(bookingId); + + // Retire the intent the way an expiry sweep would, then let the money land. + await db( + `UPDATE edr_payment.payment_intent + SET status = 'EXPIRED', expires_at = now() - interval '1 minute' + WHERE id = $1`, + [intent.id], + ); + await gateway.webhook({ + merchantOrderId: intent.merchant_order_id, + eventId: `LATE-${intent.merchant_order_id}`, + }); + + const captured = await poll<{ status: string }>( + "late capture flips the intent", + `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, + [intent.id], + (row) => row?.status === "SUCCEEDED", + { attempts: 20, intervalMs: 1000 }, + ); + expect(captured.status).toBe("SUCCEEDED"); + + const invoice = await poll<{ status: string }>( + "invoice settled by late capture", + `SELECT status FROM freight.invoices WHERE id = $1`, + [invoiceId], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + expect(invoice.status).toBe("PAID"); + }); + + it("retries delivery until the consumer is back", async () => { + // Deliberately not covered here: it needs the freight container stopped + // mid-test, which would break every other file sharing this stack. + // `node integration/scripts/it.mjs logs` + a manual `docker compose stop + // freight-api-e2e` reproduces it; the relay's backoff is unit-testable. + // ponytail: outbox retry asserted only via attempts>0 below; add a + // dedicated single-file stack if this ever regresses. + const rows = await db<{ status: string; attempts: number }>( + `SELECT status, attempts FROM edr_payment.notification_outbox ORDER BY created_at DESC LIMIT 20`, + ); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.status !== "FAILED")).toBe(true); + }); +}); diff --git a/integration/src/payment-happy.it.ts b/integration/src/payment-happy.it.ts new file mode 100644 index 000000000..83e914132 --- /dev/null +++ b/integration/src/payment-happy.it.ts @@ -0,0 +1,178 @@ +/** + * The freight ⇄ payment happy path, end to end through both services. + * + * portal pays invoice + * → freight billing.payInvoice → payment API /payments/initiate + * → CBE Birr provider → gateway mock (intent opened, invoice.payment_id set) + * gateway calls back (correctly signed) + * → payment API webhook pipeline → intent SUCCEEDED → outbox row + * → RabbitMQ → freight consumer → settleByPaymentId + * → invoice PAID → booking.invoice.paid → booking advances + * + * Nothing here is stubbed except the bank itself. + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + closeDb, + customerA, + db, + gateway, + intentByMerchantOrderId, + poll, +} from "./client"; +import { + createImportSchedule, + currentInvoice, + departureAt, + ensureCorridorRoute, + releaseUnpaidHolds, + forceWindowOpen, + freightPayment, + gatewayIntent, + invoiceForBooking, + payInvoice, + prepareBooking, + resetCorridorDay, + runBatch, + type ReadyBooking, +} from "./flows"; + +const DEPARTURE = departureAt(4); +const STAMP = String(Date.now()); + +describe("freight invoice settles through the real payment service", () => { + let booking: ReadyBooking; + let invoiceId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + const schedule = await createImportSchedule({ departure: DEPARTURE }); + await forceWindowOpen(schedule.id, 45); + + booking = await prepareBooking({ + suffix: "PAY1", + departure: DEPARTURE, + runStamp: STAMP, + isoSeed: 0, + twenty: 2, + }); + await runBatch(schedule.id); + invoiceId = (await invoiceForBooking(booking.bookingId)).id; + }); + + afterAll(closeDb); + + it("opens a gateway intent and links it to the invoice", async () => { + const res = await payInvoice(invoiceId, { method: "CBE_BIRR" }); + expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); + + const intent = await gatewayIntent(booking.bookingId); + expect(intent.provider).toBe("CBE_BIRR"); + expect(intent.status).toBe("REQUIRES_ACTION"); + expect(intent.merchant_order_id).toBeTruthy(); + + // The invoice must carry the intent id BEFORE any callback can arrive — + // settlement correlates on it (billing.service.ts payInvoice). + const invoice = await currentInvoice(invoiceId); + expect(invoice.payment_id).toBeTruthy(); + + // Freight's local projection of the same intent. + const projection = await freightPayment(invoice.payment_id!); + expect(projection.status).toBe("action-required"); + expect(projection.merchant_order_id).toBe(intent.merchant_order_id); + + // Freight deliberately sends a dev-shortcut amount for non-CBE_BILL + // providers (payment.service.ts:238-247) — 1 minor unit, not the invoice + // total. Asserted, not "fixed": changing it is a product decision. + expect(Number(intent.amount_minor)).toBe(1); + }); + + it("settles the invoice and advances the booking when the gateway calls back", async () => { + const intent = await gatewayIntent(booking.bookingId); + const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id }); + expect(res.body.delivered).toBe(200); + + const settled = await poll<{ status: string }>( + "payment intent SUCCEEDED", + `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, + [intent.id], + (row) => row?.status === "SUCCEEDED", + { attempts: 20, intervalMs: 1000 }, + ); + expect(settled.status).toBe("SUCCEEDED"); + + // The outbox row is written in the SAME transaction as the intent update + // and relayed to RabbitMQ by the relay loop. + const outbox = await poll<{ status: string; attempts: number }>( + "outbox row relayed", + `SELECT status, attempts FROM edr_payment.notification_outbox + WHERE intent_id = $1 AND event_type = 'payment.succeeded'`, + [intent.id], + (row) => row?.status === "SENT", + { attempts: 20, intervalMs: 1000 }, + ); + expect(outbox.status).toBe("SENT"); + + // …and freight, on the other end of the broker, settles the invoice. + const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>( + "invoice PAID", + `SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`, + [invoiceId], + (row) => row?.status === "PAID", + { attempts: 30, intervalMs: 2000 }, + ); + expect(Number(invoice.balance_amount)).toBe(0); + expect(Number(invoice.paid_amount)).toBeGreaterThan(0); + + const booked = await poll<{ status: string }>( + "booking advanced on payment", + `SELECT status FROM freight.bookings WHERE id = $1`, + [booking.bookingId], + (row) => row?.status === "PAID", + { attempts: 20, intervalMs: 2000 }, + ); + expect(booked.status).toBe("PAID"); + }); + + it("records exactly one intent, one webhook event and one ledger entry", async () => { + const intent = await gatewayIntent(booking.bookingId); + const intents = await intentByMerchantOrderId(intent.merchant_order_id); + expect(intents.length).toBe(1); + + const [{ n }] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event + WHERE merchant_order_id = $1`, + [intent.merchant_order_id], + ); + expect(Number(n)).toBe(1); + + const invoice = await currentInvoice(invoiceId); + const ledger = (invoice.payments ?? []) as unknown[]; + expect(Array.isArray(ledger) ? ledger.length : 0).toBe(1); + }); + + it("is idempotent — a replayed callback changes nothing", async () => { + const intent = await gatewayIntent(booking.bookingId); + const before = await currentInvoice(invoiceId); + + // Same orderId ⇒ same externalEventId ⇒ deduped at the webhook table. + await gateway.webhook({ + merchantOrderId: intent.merchant_order_id, + eventId: `CBEORD-${intent.merchant_order_id}`, + }); + await new Promise((r) => setTimeout(r, 3000)); + + const after = await currentInvoice(invoiceId); + expect(after.status).toBe("PAID"); + expect(after.paid_amount).toBe(before.paid_amount); + expect((after.payments as unknown[]).length).toBe((before.payments as unknown[]).length); + }); + + it("refuses a second payment on an already-paid invoice", async () => { + const res = await payInvoice(invoiceId, { method: "CBE_BIRR", as: customerA }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); +}); diff --git a/integration/tsconfig.json b/integration/tsconfig.json new file mode 100644 index 000000000..ba9965338 --- /dev/null +++ b/integration/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "vitest.config.ts"] +} diff --git a/integration/vitest.config.ts b/integration/vitest.config.ts new file mode 100644 index 000000000..348a1303d --- /dev/null +++ b/integration/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.it.ts"], + globalSetup: ["src/global-setup.ts"], + // One shared containerized stack and one shared database: files run in + // sequence. Concurrency is exercised INSIDE tests (Promise.all), which is + // what the guards under test actually see in production. + fileParallelism: false, + testTimeout: 180_000, + hookTimeout: 180_000, + // Booking/scheduling steps are not idempotent — a retry would assert + // against a half-advanced booking. + retry: 0, + reporters: ["verbose"], + }, +}); diff --git a/package.json b/package.json index cfa584a12..47553fa91 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run", "e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci", "e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down", + "it:up": "node integration/scripts/it.mjs up", + "it:test": "node integration/scripts/it.mjs test", + "it:logs": "node integration/scripts/it.mjs logs", + "it:down": "node integration/scripts/it.mjs down", "prepare": "husky" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7df61a873..8e3fe21a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -589,7 +589,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1239,6 +1239,30 @@ importers: specifier: ^5.5.4 version: 5.9.3 + integration: + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + '@types/pg': + specifier: ^8.11.0 + version: 8.20.0 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + pg: + specifier: ^8.13.0 + version: 8.21.0 + supertest: + specifier: ^7.0.0 + version: 7.2.2 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^2.1.2 + version: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0) + packages/api-common: dependencies: '@edr/types': @@ -12340,11 +12364,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12379,7 +12403,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -12388,14 +12412,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12410,9 +12427,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -12427,13 +12444,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -12586,18 +12603,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -12821,7 +12826,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -12987,7 +12992,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -13147,7 +13152,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -13177,6 +13182,14 @@ snapshots: optionalDependencies: '@types/node': 20.19.42 + '@inquirer/confirm@6.1.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@22.20.1) + '@inquirer/type': 4.0.7(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + optional: true + '@inquirer/confirm@6.1.1(@types/node@24.13.1)': dependencies: '@inquirer/core': 11.2.1(@types/node@24.13.1) @@ -13197,6 +13210,19 @@ snapshots: optionalDependencies: '@types/node': 20.19.42 + '@inquirer/core@11.2.1(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@22.20.1) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.20.1 + optional: true + '@inquirer/core@11.2.1(@types/node@24.13.1)': dependencies: '@inquirer/ansi': 2.0.7 @@ -13319,6 +13345,11 @@ snapshots: optionalDependencies: '@types/node': 20.19.42 + '@inquirer/type@4.0.7(@types/node@22.20.1)': + optionalDependencies: + '@types/node': 22.20.1 + optional: true + '@inquirer/type@4.0.7(@types/node@24.13.1)': optionalDependencies: '@types/node': 24.13.1 @@ -14318,7 +14349,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -16386,7 +16417,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -16675,130 +16706,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -17162,7 +17069,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -17172,7 +17079,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -17191,7 +17098,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -17206,7 +17113,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -17330,6 +17237,15 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 + '@vitest/mocker@2.1.9(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.14.6(@types/node@22.20.1)(typescript@5.9.3) + vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) + '@vitest/mocker@2.1.9(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@vitest/spy': 2.1.9 @@ -17486,7 +17402,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -18010,16 +17926,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -18173,7 +18079,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -19174,7 +19080,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -19194,7 +19100,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -19427,7 +19333,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -19555,7 +19461,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -19785,7 +19691,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -19838,7 +19744,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -19991,7 +19897,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -20237,7 +20143,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20518,7 +20424,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20531,14 +20437,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -20963,7 +20869,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -21611,7 +21517,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -22012,6 +21918,32 @@ snapshots: ms@2.1.3: {} + msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@22.20.1) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.1 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.1 + type-fest: 5.7.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3): dependencies: '@inquirer/confirm': 6.1.1(@types/node@24.13.1) @@ -22386,7 +22318,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -22726,7 +22658,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -22755,7 +22687,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -23008,15 +22940,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -23590,7 +23513,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -23708,7 +23631,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -23926,7 +23849,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -23936,7 +23859,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -23947,7 +23870,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -23956,7 +23879,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -23968,7 +23891,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -24248,24 +24171,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -24291,7 +24196,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -24798,7 +24703,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -24822,7 +24727,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -25122,10 +25027,28 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@5.5.0) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -25140,6 +25063,17 @@ snapshots: - supports-color - terser + vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.61.1 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + lightningcss: 1.32.0 + terser: 5.48.0 + vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: esbuild: 0.21.5 @@ -25151,6 +25085,42 @@ snapshots: lightningcss: 1.32.0 terser: 5.48.0 + vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3(supports-color@5.5.0) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) + vite-node: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vitest@2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0): dependencies: '@vitest/expect': 2.1.9 @@ -25161,7 +25131,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e5e748478..14f686558 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - "packages/*" - "packages/config/*" - "e2e/*" + - "integration" verifyDepsBeforeRun: warn allowBuilds: "@nestjs/core": true From 72532f36cd84cb7676737e8d7156730029e5506f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 3 Aug 2026 11:58:33 +0000 Subject: [PATCH 02/10] fix: seeder --- apps/edr-freight-api/src/app.module.ts | 18 ++++---- .../src/seed/edr-org.seeder.ts | 41 +++++++++++-------- integration/scripts/it.mjs | 6 ++- 3 files changed, 39 insertions(+), 26 deletions(-) diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a3c09df97..73e28205e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -65,7 +65,7 @@ import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; // import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; -import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; +// import { YardFacilitiesSeeder } from "./seed/yard-facilities.seeder"; // import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; // import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; // import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; @@ -226,7 +226,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar EdrOrgSeeder, FreightPositionsSeeder, FileUploadSettingsSeeder, - YardFacilitiesSeeder, + // YardFacilitiesSeeder, FreightPermissionKeyMigrationSeeder, // Disabled seeds — providers commented out (imports/injection/run too): // DemoUsersSeeder, @@ -253,7 +253,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, - private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, + // private readonly yardFacilitiesSeeder: YardFacilitiesSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, // Disabled seeds — injections commented out (imports/provider/run too): // private readonly demoUsersSeeder: DemoUsersSeeder, @@ -302,7 +302,7 @@ export class AppModule implements OnApplicationBootstrap { // Flags which yards can load/unload cargo (Indode, Sebeta, Modjo, Adama, // Dire Dawa). Idempotent; creates no yards. - await this.yardFacilitiesSeeder.run(); + // await this.yardFacilitiesSeeder.run(); // Dropdown settings are not seeded on boot; run them with // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). @@ -333,9 +333,11 @@ export class AppModule implements OnApplicationBootstrap { configure(consumer: MiddlewareConsumer) { consumer.apply(LoggerMiddleware).forRoutes("*"); - consumer.apply(LoginAudienceMiddleware).forRoutes( - { path: "auth/login", method: RequestMethod.POST }, - { path: "auth/mfa-verify", method: RequestMethod.POST }, - ); + consumer + .apply(LoginAudienceMiddleware) + .forRoutes( + { path: "auth/login", method: RequestMethod.POST }, + { path: "auth/mfa-verify", method: RequestMethod.POST }, + ); } } diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 9f10c3e0e..cd1b2c111 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -29,11 +29,13 @@ type SeedOrganization = { export class EdrOrgSeeder { private readonly logger = new Logger(EdrOrgSeeder.name); - constructor(private readonly dataSource: DataSource) {} + constructor(private readonly dataSource: DataSource) { } async run() { if (!this.shouldSeed()) { - this.logger.log(`Skipping EDR org seed because ${SEED_FLAG} is not enabled`); + this.logger.log( + `Skipping EDR org seed because ${SEED_FLAG} is not enabled`, + ); return; } @@ -95,20 +97,22 @@ export class EdrOrgSeeder { manager: EntityManager, organizationId: string, ) { - const organizationConfigurationRepository = - manager.getRepository(OrganizationConfiguration); - - await organizationConfigurationRepository.upsert({ - organizationId, - canCreateBranchByItself: true, - canStartReceivingRecord: true, - }, { - conflictPaths: { organizationId: true }, - }); - - this.logger.log( - `Ensured organization configuration for '${EDR_ORG_KEY}'`, + const organizationConfigurationRepository = manager.getRepository( + OrganizationConfiguration, ); + + await organizationConfigurationRepository.upsert( + { + organizationId, + canCreateBranchByItself: true, + canStartReceivingRecord: true, + }, + { + conflictPaths: { organizationId: true }, + }, + ); + + this.logger.log(`Ensured organization configuration for '${EDR_ORG_KEY}'`); } private async ensureDefaultUnit( @@ -152,7 +156,9 @@ export class EdrOrgSeeder { key: EDR_FREIGHT_APPLICATION.key, name: { ...EDR_FREIGHT_APPLICATION.name }, }); - this.logger.log(`Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`); + this.logger.log( + `Seeded EDR application '${EDR_FREIGHT_APPLICATION.key}'`, + ); return { id: insertResult.identifiers[0]?.id as string }; } @@ -181,7 +187,8 @@ export class EdrOrgSeeder { applicationId, })), ) - .orUpdate(["name", "application_id"], ["key"]) + .orIgnore() + // .orUpdate(["name", "application_id"], ["key"]) .execute(); this.logger.log( diff --git a/integration/scripts/it.mjs b/integration/scripts/it.mjs index a30e446b5..e3e2b3075 100644 --- a/integration/scripts/it.mjs +++ b/integration/scripts/it.mjs @@ -143,7 +143,11 @@ function up() { } } -const [cmd, ...extra] = process.argv.slice(2); +const [cmd, ...rawExtra] = process.argv.slice(2); +// `pnpm it:test -- src/foo.it.ts` hands us a literal "--" first. Forwarding it +// makes vitest treat everything after it as CLI options and ignore the file +// filter — the "one file" run silently becomes the whole suite. +const extra = rawExtra[0] === "--" ? rawExtra.slice(1) : rawExtra; switch (cmd) { case "up": From 106b07a02be58c09d960310bd65742ae1f10d164 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 12:32:10 +0000 Subject: [PATCH 03/10] add staff user list endpoint --- .../modules/auth/dto/list-users-query.dto.ts | 38 ++++++++++ .../src/modules/auth/freight-auth.module.ts | 4 ++ .../src/modules/auth/list-users.controller.ts | 22 ++++++ .../src/modules/auth/list-users.service.ts | 69 +++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/auth/list-users.controller.ts create mode 100644 apps/edr-freight-api/src/modules/auth/list-users.service.ts diff --git a/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts new file mode 100644 index 000000000..2325568e9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/dto/list-users-query.dto.ts @@ -0,0 +1,38 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { EUserStatus, EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean, IsEnum, IsIn, IsOptional } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +/** Query-string booleans arrive as strings; implicit conversion is off app-wide. */ +const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined => + value === undefined || value === null || value === '' + ? undefined + : value === true || value === 'true'; + +export class ListUsersQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: EUserType }) + @IsOptional() + @IsEnum(EUserType) + userType?: EUserType; + + @ApiPropertyOptional({ enum: EUserStatus }) + @IsOptional() + @IsEnum(EUserStatus) + userStatus?: EUserStatus; + + @ApiPropertyOptional({ description: 'Filter by active flag.' }) + @IsOptional() + @Transform(toOptionalBoolean) + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ + enum: ['username', 'email', 'createdAt'], + default: 'username', + }) + @IsOptional() + @IsIn(['username', 'email', 'createdAt']) + sortBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 10dbd0b37..ff8f803b9 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -19,6 +19,8 @@ import { ForgotPasswordController } from './forgot-password.controller'; import { ForgotPasswordService } from './forgot-password.service'; import { FreightMeController } from './freight-me.controller'; import { FreightMeService } from './freight-me.service'; +import { ListUsersController } from './list-users.controller'; +import { ListUsersService } from './list-users.service'; @Module({ imports: [ @@ -39,8 +41,10 @@ import { FreightMeService } from './freight-me.service'; CheckAvailabilityController, ForgotPasswordController, CustomerResetController, + ListUsersController, ], providers: [ + ListUsersService, FreightMeService, AccountService, CheckAvailabilityService, diff --git a/apps/edr-freight-api/src/modules/auth/list-users.controller.ts b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts new file mode 100644 index 000000000..e7fbfd771 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { ListUsersService } from './list-users.service'; +import { StaffReference } from '../../common/booking-guards'; + +@ApiTags('auth') +@Controller('staff/users') +@ApiBearerAuth() +export class ListUsersController { + constructor(private readonly service: ListUsersService) {} + + @Get() + @StaffReference() + @ApiOperation({ + summary: 'List IAM users (paginated) for backoffice pickers', + }) + findAll(@Query() query: ListUsersQueryDto) { + return this.service.findAll(query); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/list-users.service.ts b/apps/edr-freight-api/src/modules/auth/list-users.service.ts new file mode 100644 index 000000000..cf7e22be2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/list-users.service.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { PaginatedResponse } from '@edr/types'; +import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Repository } from 'typeorm'; + +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { paginateQuery } from '../../common/utils/pagination.util'; + +/** + * Read-only listing of `iam.users` for backoffice pickers. + * + * Exists because `@tria-plc/iamapi-common@1.0.0`'s `GET /users/filter` pairs a + * `@QueryParams()` pagination DTO with a plain `@Query()` DTO that does not + * declare `skip`/`take`/`orderBy`; the global whitelist pipe then 400s on the + * very params the route's own paginator reads. Drop this once IAM ships a fix. + */ +@Injectable() +export class ListUsersService { + constructor( + @InjectRepository(User) private readonly users: Repository, + ) {} + + findAll(query: ListUsersQueryDto): Promise> { + const sortBy = query.sortBy ?? 'username'; + const qb = this.users + .createQueryBuilder('user') + // Explicit select: never widen this to `user` — the entity's lazy + // relations include credentials and sessions. + .select([ + 'user.id', + 'user.name', + 'user.username', + 'user.email', + 'user.phoneNumber', + 'user.userType', + 'user.status', + 'user.isActive', + 'user.createdAt', + ]) + .orderBy(`user.${sortBy}`, query.sortOrder ?? 'ASC'); + + if (query.userType) { + qb.andWhere('user.userType = :userType', { userType: query.userType }); + } + if (query.userStatus) { + qb.andWhere('user.status = :userStatus', { userStatus: query.userStatus }); + } + if (query.isActive !== undefined) { + qb.andWhere('user.isActive = :isActive', { isActive: query.isActive }); + } + if (query.search) { + // `name` is localized jsonb ({ en, am, … }), not a string — match its + // values rather than casting the whole object to text. + qb.andWhere( + `(user.username ILIKE :search + OR user.email ILIKE :search + OR user.phone_number ILIKE :search + OR EXISTS ( + SELECT 1 FROM jsonb_each_text(user.name) AS n(k, v) + WHERE n.v ILIKE :search + ))`, + { search: `%${query.search}%` }, + ); + } + + return paginateQuery(qb, query); + } +} From 3b45990023bd96d97315d85f4894dcc23f528014 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 3 Aug 2026 12:57:32 +0000 Subject: [PATCH 04/10] chore: more test cases --- integration/README.md | 21 ++ integration/src/flows.ts | 32 ++ integration/src/g2-weight.it.ts | 572 ++++++++++++++++++++++++++++++++ integration/src/global-setup.ts | 2 + 4 files changed, 627 insertions(+) create mode 100644 integration/src/g2-weight.it.ts diff --git a/integration/README.md b/integration/README.md index 65550aa0f..b35d682ce 100644 --- a/integration/README.md +++ b/integration/README.md @@ -75,6 +75,7 @@ gateway-mock-it`) — the code is a read-only mount, not baked into an image. | `src/g1-s4-split-closes-gap.it.ts` | container split closes the last 3-wagon gap, 14-box remainder | | `src/g1-s5-cascading-expiry.it.ts` | one settle promotes twice; expiries terminal, invoices closed | | `src/g1-s6-s8-offers-government-tiers.it.ts` | ignored offer, government preemption, USD/customs/plain tiers | +| `src/g2-weight.it.ts` | weight before slots: base pull, overage tolerance, split sized on base only, light cargo | | `src/flows.ts` | freight business steps, ported from `e2e/freight/cypress/e2e/flows/import-utils.ts` | ## Findings pinned by these tests @@ -101,6 +102,20 @@ what it actually does and says so in a comment, so a fix fails loudly: priority config. - **Freight sends a dev-shortcut amount** (1 minor unit, 10 for CAC) for every non-`CBE_BILL` provider, with no short-payment guard. +- **A BUILT train's batch is blind to the pull limit** (`g2-weight`, last + describe). `remainingBudget` replaces the locomotive limits with + `{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the + moment a schedule has a built train, so the batch reserves — and invoices — + a load the locomotives cannot pull. The only check left is at wagon + allocation, which then fails every tick with "Train set locomotives cannot + pull the gross weight … limit 3500T incl. tolerance". The customer is PAID + with zero wagons. Group 2 therefore runs its real weight scenarios on + locomotive PAIRS, where the limits survive. +- **The allocator weighs the whole consist, the batch weighs the booking.** + Allocation charges the tare of every wagon in the train set (53 × 22.4 T on + the G2 consist), while `needFor` charges only the tare of the wagons the + booking occupies — so the same 45-wagon load reads 3 528 T at reservation and + 3 707.2 T at allocation. Two capacity models, one train. - **Government preemption cannot reach a FULL train** (`g1-s6-s8`). `isFillable` rejects a schedule whose `booking_window_status` is FULL before any budget or victim is considered, and `refreshWindowStatus` re-derives that flag from live @@ -143,6 +158,12 @@ what it actually does and says so in a comment, so a fix fails loudly: - **One tenant per booking.** `seedTenantContracts` mints a company per booking because a company may hold only one unpaid reservation at a time; staff book and pay on their behalf, which is also the real Path B flow. +- **The weight axis is GROSS, but the column is not.** + `wagon_booking_allocations.allocated_weight_tons` holds CARGO only; + `allocatedGrossTons` adds each wagon type's tare, because the pull limit is + spent on both. Two 20ft at 28 T ride one wagon at 78.4 T gross — 35 of those + spend a 3 500 T locomotive pair, and reading the raw column would report + 1 960 T and hide it. - **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here) every fill pass. A built train's coupled consist wins outright, so diff --git a/integration/src/flows.ts b/integration/src/flows.ts index 96867b0ec..eaab5a6f4 100644 --- a/integration/src/flows.ts +++ b/integration/src/flows.ts @@ -1492,6 +1492,38 @@ export async function expectContainersPlaced(scheduleId: string, containers: num } } +/** + * GROSS tonnage riding a schedule — cargo PLUS the tare of every wagon it + * occupies, because a locomotive hauls the wagon as well as what is in it. + * + * `allocated_weight_tons` holds the CARGO alone, so the tare of each allocated + * wagon type is added here. Reading the column raw understates a loaded consist + * by 22.4 T a wagon and makes a weight-bound train look half empty. + */ +export async function allocatedGrossTons(scheduleId: string): Promise { + const [row] = await db<{ tons: string | null }>( + `SELECT COALESCE(sum(wba.allocated_weight_tons + wt.tare_weight_tons), 0)::text AS tons + FROM freight.wagon_booking_allocations wba + JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id + JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + return Number(row.tons ?? 0); +} + +/** Wagons a single booking currently holds. */ +export async function wagonAllocationCount(bookingId: string): Promise { + const [row] = await db<{ n: string }>( + `SELECT count(*)::text AS n FROM freight.wagon_booking_allocations + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return Number(row.n); +} + /** Bookings still linked to a schedule — the seats actually held. */ export async function linkedBookings(scheduleId: string): Promise { const [row] = await db<{ n: string }>( diff --git a/integration/src/g2-weight.it.ts b/integration/src/g2-weight.it.ts new file mode 100644 index 000000000..58ce74786 --- /dev/null +++ b/integration/src/g2-weight.it.ts @@ -0,0 +1,572 @@ +/** + * GROUP 2 · S9–S12 — when WEIGHT binds before slots. + * + * Group 1 kept every non-slot axis slack so wagon arithmetic was the only thing + * under test. This group inverts it: the locomotives pull 3 500 T base (two + * 1 750 T units — pull weight ADDS UP across a set) and the cargo is heavy + * enough that the pull limit runs out before the slots do. + * + * The arithmetic follows from `grossWagonWeightTons` = tare + cargo. The weight + * axis is GROSS: a locomotive hauls the wagon as well as what is in it. NW5 + * tare 22.4 T, two 20ft per wagon: + * + * heavy (28 T VGM): 2 × 28 + 22.4 = 78.4 T per wagon + * light (12 T VGM): 2 × 12 + 22.4 = 46.4 T per wagon + * + * S9 35 wagons × 78.4 = 2 744 T fits; 10 more would be 3 528 T > 3 500 T, + * so the next booking is cut down on WEIGHT and the window closes FULL + * with 19 slots still empty — the verdict names pull, not slots. + * S10 that same 3 528 T is admitted WHOLE by the pair carrying a 90 T + * tolerance (cap 3 590 T). Tolerance buys a whole booking, nothing else. + * S11 with 756 T of base room left, the split offer is sized from BASE room + * only — it may never reach into the tolerance. + * S12 light cargo: every slot fills at ~72% of the pull limit. SLOTS bind. + * + * WHY LOCOMOTIVE PAIRS AND NOT THE BUILT TRAINS + * + * `remainingBudget` replaces the whole limit set with + * `{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the + * moment a schedule has a built train — so on a built consist the batch is + * blind to pull weight and only slots bind. The locomotive-pair path keeps the + * real limits, which is where this group's axis actually lives. The last + * describe pins the built-train hole itself: the batch reserves a load the + * locomotives cannot pull, and only the allocator notices — after payment. + * + * Fixture: seed-g2-weight.sql (LOCO-G2-A/B 1 750 T + 0 tolerance, + * LOCO-G2-C/D 1 750 T + 45 T each, TRN-G2-BASE for the built-train case). + */ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { closeDb, gateway, sleep } from "./client"; +import { + allocatedGrossTons, + allocatedWagons, + bookContainersReady, + bookingRow, + closeBookingWindow, + completeDocReview, + createBuiltTrainSchedule, + createSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectNoPartialOffer, + extendPayWindow, + forceWindowOpen, + payViaGateway, + pollAllocations, + pollBookingStatus, + pollCycleConcluded, + pollPartialOffer, + pollWindow, + releaseUnpaidHolds, + resetCorridorDay, + scheduleRow, + seedTenantContracts, + setPriority, + wagonAllocationCount, +} from "./flows"; + +const STAMP = String(Date.now()); + +/** NW5 tare and capacity — the fixture's whole premise. */ +const NW5_TARE = 22.4; +const NW5_CAPACITY = 70; +const HEAVY_VGM = 28; +const LIGHT_VGM = 12; +/** Two 20ft ride one NW5. */ +const grossPerWagon = (vgm: number) => 2 * vgm + NW5_TARE; +/** Base pull of the 1 750 T + 1 750 T pair. */ +const BASE_TONS = 3500; +/** LOCO-G2-C/D: 45 T + 45 T. Weight tolerance adds up too. */ +const TOLERANCE_TONS = 90; +const BASE_PAIR: [string, string] = ["LOCO-G2-A", "LOCO-G2-B"]; +const TOL_PAIR: [string, string] = ["LOCO-G2-C", "LOCO-G2-D"]; +/** floor(760 m / 13.966 m) — the slot count a 760 m pair derives. */ +const SLOTS = 54; + +/** + * How the engine sizes a partial when weight is the binding axis: it walks + * candidate wagon counts and keeps the one carrying the most cargo, measuring + * each wagon at its FULL capacity rather than at the booking's real density. + * With 756 T of room that peaks at 8 wagons (8 × 70 = 560 T of nominal cargo) + * rather than 9 (756 − 9 × 22.4 = 554.4 T), even though this cargo only weighs + * 56 T per wagon. Conservative, and never dependent on the tolerance. + */ +function offerWagonsFor(roomTons: number, bookingWagons: number, freeSlots: number): number { + let best = 0; + let bestCargo = 0; + for (let w = 1; w <= Math.min(freeSlots, bookingWagons - 1); w += 1) { + const cargo = Math.min(w * NW5_CAPACITY, roomTons - w * NW5_TARE); + if (cargo > bestCargo) { + bestCargo = cargo; + best = w; + } + } + return best; +} + +// ─────────────────────────────────────────────────────────────────────────── +// S9 — weight binds before slots +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s9: weight cuts a booking down while slots sit empty", () => { + const DEPARTURE = departureAt(58); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + WA: { twenty: 40, wagons: 20 }, + WB: { twenty: 30, wagons: 15 }, + WC: { twenty: 20, wagons: 10 }, + } as const; + const ORDER = ["WA", "WB", "WC"] as const; + const BOARDED = SHAPES.WA.wagons + SHAPES.WB.wagons; // 35 + const FREE_SLOTS = SLOTS - BOARDED; // 19 + /** 3 500 − 2 744 = 756 T of pull left, against 19 free slots. */ + const BASE_ROOM = BASE_TONS - BOARDED * grossPerWagon(HEAVY_VGM); // 756 + const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.WC.wagons, FREE_SLOTS); // 8 + + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 30_000; + for (const [i, suffix] of ORDER.entries()) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("the heavy-wagon arithmetic is what the scenario assumes", async () => { + expect(grossPerWagon(HEAVY_VGM), "2 × 28 T + 22.4 T tare").toBe(78.4); + expect(BOARDED * grossPerWagon(HEAVY_VGM), "35 wagons fit the 3 500 T base").toBe(2744); + expect( + (BOARDED + SHAPES.WC.wagons) * grossPerWagon(HEAVY_VGM), + "WC's 10 more would breach the base", + ).toBeGreaterThan(BASE_TONS); + expect(Number((await scheduleRow(scheduleId)).max_wagons), "54 slots").toBe(SLOTS); + expect(FREE_SLOTS, "19 slots would still be free").toBe(19); + }); + + it("WA and WB board whole; WC is cut down by WEIGHT, not by slots", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + + for (const suffix of ["WA", "WB"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + + // 19 slots are free and WC needs 10 — on the slot axis it fits easily. What + // it gets instead is an offer sized by the 756 T of pull left. + const offered = Number((await pollPartialOffer(booking.get("WC")!)).offered_wagons); + expect(offered, "sized by remaining pull, not by slots").toBe(EXPECTED_OFFER); + expect(offered, "less than the 10 wagons WC asked for").toBeLessThan(SHAPES.WC.wagons); + expect(offered, "and far less than the free slots").toBeLessThan(FREE_SLOTS); + expect( + offered * grossPerWagon(HEAVY_VGM), + "the offered part fits the base room", + ).toBeLessThanOrEqual(BASE_ROOM); + }); + + it("the verdict names WEIGHT: 35 of 54 slots with the pull limit spent", async () => { + await extendPayWindow(scheduleId, [booking.get("WA")!, booking.get("WB")!]); + for (const suffix of ["WA", "WB"] as const) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + + const tons = await allocatedGrossTons(scheduleId); + expect(tons, "2 744 T of the 3 500 T base used").toBeCloseTo(2744, 0); + expect( + tons + SHAPES.WC.wagons * grossPerWagon(HEAVY_VGM), + "WC whole would not fit on weight", + ).toBeGreaterThan(BASE_TONS); + expect(await allocatedWagons(scheduleId), "35 of 54 slots used").toBe(BOARDED); + // And the verdict itself: the window reads FULL with 19 slots standing + // empty, because `isExhausted` ran out of PULL, not of wagons. On the built + // trains of Group 1 the same board would still be selling space. + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "FULL — declared on weight while 19 slots are free", + ).toBe("FULL"); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S10 — tolerance admits a WHOLE booking over the base +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s10: the overage tolerance admits the last booking whole", () => { + const DEPARTURE = departureAt(59); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + TA: { twenty: 40, wagons: 20 }, + TB: { twenty: 30, wagons: 15 }, + TC: { twenty: 20, wagons: 10 }, + } as const; + const ORDER = ["TA", "TB", "TC"] as const; + const ALL_WAGONS = 45; + const ALL_TONS = ALL_WAGONS * grossPerWagon(HEAVY_VGM); // 3528 + + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 31_000; + for (const [i, suffix] of ORDER.entries()) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("3 528 T breaks the 3 500 T base but sits inside the 3 590 T cap", () => { + expect(ALL_TONS, "45 heavy wagons").toBeCloseTo(3528, 6); + expect(ALL_TONS, "over base").toBeGreaterThan(BASE_TONS); + expect(ALL_TONS, "within base + tolerance").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS); + }); + + it("TC — the booking S9 could not fit — is admitted WHOLE, not split", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ORDER) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + // The scenario's whole claim: the tolerance is spent admitting a WHOLE + // booking. A split offer here would be the bug. + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + }); + + it("the train rides over base, inside tolerance, at 45 of 54 slots", async () => { + await extendPayWindow(scheduleId, [...booking.values()]); + for (const suffix of ORDER) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + await endPaymentPhase(scheduleId); + await pollCycleConcluded(scheduleId); + + expect(await allocatedWagons(scheduleId), "45 of 54 slots").toBe(ALL_WAGONS); + const tons = await allocatedGrossTons(scheduleId); + expect(tons, "3 528 T aboard").toBeCloseTo(ALL_TONS, 0); + expect(tons, "over the 3 500 T base").toBeGreaterThan(BASE_TONS); + expect(tons, "inside the 3 590 T cap").toBeLessThanOrEqual(BASE_TONS + TOLERANCE_TONS); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S11 — a split may never touch the tolerance +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s11: a split is sized against base weight only", () => { + const DEPARTURE = departureAt(60); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + XA: { twenty: 40, wagons: 20 }, + XB: { twenty: 30, wagons: 15 }, + /** Wants 15 wagons; base room pays for a fraction of them. */ + XD: { twenty: 30, wagons: 15 }, + } as const; + const ORDER = ["XA", "XB", "XD"] as const; + const USED_TONS = 35 * grossPerWagon(HEAVY_VGM); // 2744 + const BASE_ROOM = BASE_TONS - USED_TONS; // 756 + const TOLERANCE_ROOM = BASE_ROOM + TOLERANCE_TONS; // 846 + const FREE_SLOTS = SLOTS - 35; // 19 + const EXPECTED_OFFER = offerWagonsFor(BASE_ROOM, SHAPES.XD.wagons, FREE_SLOTS); // 8 + /** What the tolerance would have bought if the sizer were allowed to spend it. */ + const OFFER_IF_TOLERANCE_SPENT = offerWagonsFor(TOLERANCE_ROOM, SHAPES.XD.wagons, FREE_SLOTS); + + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + // Deliberately the TOLERANCE pair: the 90 T is present and must go unspent. + scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: TOL_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 32_000; + for (const [i, suffix] of ORDER.entries()) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + await setPriority(booking.get(suffix)!, i + 1); + } + }, 1_800_000); + + it("base room and tolerance room would buy different offers", () => { + expect(BASE_ROOM, "756 T of base room after 2 744 T").toBe(756); + expect( + OFFER_IF_TOLERANCE_SPENT, + "spending the 90 T would buy a bigger offer — so this is a real distinction", + ).toBeGreaterThan(EXPECTED_OFFER); + }); + + it("XD's offer is sized from base room — the tolerance stays unspent", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ["XA", "XB"] as const) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + } + + const offered = Number((await pollPartialOffer(booking.get("XD")!)).offered_wagons); + expect(offered, "split sized from BASE room only").toBe(EXPECTED_OFFER); + expect( + offered * grossPerWagon(HEAVY_VGM), + "the offered part never reaches past the base room", + ).toBeLessThanOrEqual(BASE_ROOM); + }); + + it("the train closes on weight with 19 slots free and its tolerance unused", async () => { + await extendPayWindow(scheduleId, [booking.get("XA")!, booking.get("XB")!]); + for (const suffix of ["XA", "XB"] as const) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + expect( + await allocatedGrossTons(scheduleId), + "still inside base — no tolerance spent", + ).toBeLessThanOrEqual(BASE_TONS); + expect(await allocatedWagons(scheduleId), "35 of 54 slots").toBe(35); + expect( + (await scheduleRow(scheduleId)).booking_window_status, + "FULL on pull weight, not on slots", + ).toBe("FULL"); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// S12 — light cargo, slots bind +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 s12: with light cargo the slots bind first", () => { + const DEPARTURE = departureAt(61); + const BOOKING_DAY = eatDayStr(DEPARTURE); + const SHAPES = { + LA: { twenty: 40, wagons: 20 }, + LB: { twenty: 40, wagons: 20 }, + LC: { twenty: 28, wagons: 14 }, + } as const; + const ORDER = ["LA", "LB", "LC"] as const; + const FULL_TONS = SLOTS * grossPerWagon(LIGHT_VGM); // 2505.6 + + const booking = new Map(); + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts( + STAMP, + ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })), + ); + scheduleId = (await createSchedule({ departure: DEPARTURE, locoPair: BASE_PAIR })).id; + await forceWindowOpen(scheduleId, 60); + + let isoSeed = 33_000; + for (const suffix of ORDER) { + booking.set( + suffix, + await bookContainersReady({ + contractId: contracts.get(suffix)!, + runStamp: STAMP, + isoSeed, + twenty: SHAPES[suffix].twenty, + scheduledDate: BOOKING_DAY, + vgmTons: LIGHT_VGM, + }), + ); + isoSeed += SHAPES[suffix].twenty; + } + }, 1_800_000); + + it("a full consist of light wagons weighs only ~72% of the pull limit", () => { + expect(grossPerWagon(LIGHT_VGM), "2 × 12 T + 22.4 T tare").toBe(46.4); + expect(FULL_TONS, "54 × 46.4 T").toBeCloseTo(2505.6, 1); + expect(FULL_TONS / BASE_TONS, "~72% of base").toBeCloseTo(0.72, 1); + expect( + ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0), + "the three fill every slot", + ).toBe(SLOTS); + }); + + it("all three board and fill the train on SLOTS, with weight to spare", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + for (const suffix of ORDER) { + await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(booking.get(suffix)!, suffix); + } + + await extendPayWindow(scheduleId, [...booking.values()]); + for (const suffix of ORDER) { + await payViaGateway(booking.get(suffix)!); + await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons); + } + }); + + it("the verdict names SLOTS — every slot filled with ~1 000 T of pull unused", async () => { + await endPaymentPhase(scheduleId); + await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "FULL"); + expect(await allocatedWagons(scheduleId), "54 of 54 slots").toBe(SLOTS); + for (const suffix of ORDER) { + expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID"); + } + + const tons = await allocatedGrossTons(scheduleId); + expect(tons, "2 505 T aboard").toBeCloseTo(FULL_TONS, 0); + // The opposite of S9 on identical locomotives: the train is full because it + // ran out of WAGONS, not pull. + expect(BASE_TONS - tons, "nearly 1 000 T of pull unused").toBeGreaterThan(900); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// FINDING — on a BUILT train the batch never sees the pull limit +// ─────────────────────────────────────────────────────────────────────────── + +describe("g2 finding: a built train's batch ignores the locomotive pull limit", () => { + const DEPARTURE = departureAt(62); + const BOOKING_DAY = eatDayStr(DEPARTURE); + /** 45 wagons of heavy cargo — the exact load S10's tolerance admitted. */ + const TWENTY = 90; + const WAGONS = 45; + /** What the ALLOCATOR weighs: cargo, plus the tare of the WHOLE 53-wagon consist. */ + const ALLOCATOR_TONS = TWENTY * HEAVY_VGM + 53 * NW5_TARE; // 3707.2 + + let bookingId: string; + let scheduleId: string; + + beforeAll(async () => { + await gateway.reset(); + await releaseUnpaidHolds(); + await ensureCorridorRoute(); + await resetCorridorDay(DEPARTURE); + + const contracts = await seedTenantContracts(STAMP, [ + { suffix: "BW", freight: "CONTAINER" as const }, + ]); + scheduleId = ( + await createBuiltTrainSchedule({ + departure: DEPARTURE, + trainCode: "TRN-G2-BASE", + }) + ).id; + await forceWindowOpen(scheduleId, 60); + + bookingId = await bookContainersReady({ + contractId: contracts.get("BW")!, + runStamp: STAMP, + isoSeed: 34_000, + twenty: TWENTY, + scheduledDate: BOOKING_DAY, + vgmTons: HEAVY_VGM, + }); + }, 1_800_000); + + afterAll(closeDb); + + it("the load is beyond what these locomotives can pull, tolerance included", () => { + expect(WAGONS * grossPerWagon(HEAVY_VGM), "3 528 T on the booking's own wagons").toBeCloseTo( + 3528, + 6, + ); + expect(ALLOCATOR_TONS, "3 707.2 T once the whole consist's tare is charged").toBeCloseTo( + 3707.2, + 1, + ); + expect(ALLOCATOR_TONS, "over the 3 500 T base with no tolerance on this pair").toBeGreaterThan( + BASE_TONS, + ); + }); + + it("the batch reserves it anyway — weight is Infinity in a built train's budget", async () => { + await closeBookingWindow(scheduleId); + await completeDocReview(scheduleId); + // `remainingBudget` swaps the locomotive limits for + // {wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity} + // as soon as a schedule has a built train, so nothing weighs this booking + // until the wagons are handed out. + await pollBookingStatus(bookingId, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]); + await expectNoPartialOffer(bookingId, "BW"); + }); + + it("the customer pays, and only THEN does allocation refuse on weight", async () => { + await extendPayWindow(scheduleId, [bookingId]); + await payViaGateway(bookingId); + + // Allocation retries on every settle tick and keeps failing with + // "Train set locomotives cannot pull the gross weight … limit 3500T incl. + // tolerance". The customer is PAID with no wagons — the state this whole + // scenario group exists to surface. + await sleep(60_000); + expect(await wagonAllocationCount(bookingId), "paid, and not a single wagon").toBe(0); + expect((await bookingRow(bookingId)).status, "money taken").toBe("PAID"); + expect(await allocatedWagons(scheduleId), "the train stays empty").toBe(0); + }, 300_000); +}); diff --git a/integration/src/global-setup.ts b/integration/src/global-setup.ts index 2805a7b4e..2dec8ba49 100644 --- a/integration/src/global-setup.ts +++ b/integration/src/global-setup.ts @@ -7,6 +7,7 @@ * seed-import-corridor.sql (yards, locos, wagons, rates, distances) * seed-bulk-items.sql (PER_ITEM break-bulk cargo types) * seed-g1-train.sql (the 53-wagon BUILT container train) + * seed-g2-weight.sql (the two 3 500 T weight-bound trains) * seed-government.sql (the kind='government' company) * seed-company-b.sql (user2@gmail.com's company — this suite) * seed-customs-service-type.sql (a service type that bundles customs) @@ -30,6 +31,7 @@ const SEEDS: Array<[dir: string, file: string]> = [ [CYPRESS_FIXTURES, "seed-import-corridor.sql"], [CYPRESS_FIXTURES, "seed-bulk-items.sql"], [CYPRESS_FIXTURES, "seed-g1-train.sql"], + [CYPRESS_FIXTURES, "seed-g2-weight.sql"], [CYPRESS_FIXTURES, "seed-government.sql"], [OWN_FIXTURES, "seed-company-b.sql"], [OWN_FIXTURES, "seed-customs-service-type.sql"], From 9afc281d21994fc87a62f8916bb37cc2a0039405 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 13:06:43 +0000 Subject: [PATCH 05/10] fix issue: user trade access --- .../train-scheduling/booking-batch.service.ts | 1 + .../trade-scope.util.spec.ts | 64 +++++++++++++++++++ .../pages/configuration/TradeAccessPage.tsx | 10 +-- .../src/services/userTradeAccess.service.ts | 20 ++++++ 4 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index efdf4f691..33730b5ae 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -3020,6 +3020,7 @@ export class BookingBatchService implements OnModuleInit { : booking.status; await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, + scheduledDate: schedule.scheduledDepartureDate, status: restoredStatus, // A paid booking still hunting for a wagon keeps its flag through the // move — it only clears when wagons are actually assigned. diff --git a/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts new file mode 100644 index 000000000..8823098ab --- /dev/null +++ b/apps/edr-freight-api/src/modules/user-trade-access/trade-scope.util.spec.ts @@ -0,0 +1,64 @@ +import { applyDirectionScope, scopedDirections } from './trade-scope.util'; + +/** + * The scope decides what a restricted user may see, so the cases that matter + * are the ones where a wrong answer widens access: an unrestricted fallback + * where a restriction was configured, or an out-of-scope explicit filter + * being honoured instead of denied. + */ +describe('scopedDirections', () => { + it('leaves an unrestricted user unfiltered', () => { + expect(scopedDirections(null)).toBeNull(); + }); + + it('honours an explicit filter for an unrestricted user', () => { + expect(scopedDirections(null, 'EXPORT')).toEqual(['EXPORT']); + }); + + it('falls back to the full scope when no filter is requested', () => { + expect(scopedDirections(['EXPORT'])).toEqual(['EXPORT']); + }); + + it('narrows to the intersection when the filter is in scope', () => { + expect(scopedDirections(['IMPORT', 'EXPORT'], 'EXPORT')).toEqual(['EXPORT']); + }); + + it('denies an out-of-scope filter instead of widening access', () => { + expect(scopedDirections(['EXPORT'], 'IMPORT')).toEqual([]); + }); +}); + +describe('applyDirectionScope', () => { + const makeQb = () => { + const calls: { sql: string; params?: object }[] = []; + const qb = { + calls, + andWhere(sql: string, params?: object) { + calls.push({ sql, params }); + return qb; + }, + }; + return qb; + }; + + it('does not touch the query when unrestricted', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', null); + expect(qb.calls).toHaveLength(0); + }); + + it('matches nothing on an empty scope rather than everything', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', []); + expect(qb.calls[0].sql).toBe('1 = 0'); + }); + + it('filters to the allowed directions', () => { + const qb = makeQb(); + applyDirectionScope(qb as never, 'booking.trade_direction', ['EXPORT']); + expect(qb.calls[0].sql).toContain('booking.trade_direction IN'); + expect(qb.calls[0].params).toEqual({ + scopeDirs_booking_trade_direction: ['EXPORT'], + }); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx index 2c447d72d..842f11064 100644 --- a/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/configuration/TradeAccessPage.tsx @@ -2,10 +2,6 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { - useAllExternalUsers, - userTypeEnum, -} from "@/super-admin/hooks/useExternalUsers"; import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS, @@ -39,9 +35,9 @@ export default function TradeAccessPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); - const { data: usersResponse, isLoading: usersLoading } = useAllExternalUsers({ - userType: userTypeEnum.employee, - take: 3000, + const { data: usersResponse, isLoading: usersLoading } = useQuery({ + queryKey: ["staff-users", "employees"], + queryFn: userTradeAccessService.employees, }); const { data: configs, isLoading: configsLoading } = useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts b/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts index 8931762d5..3570e004d 100644 --- a/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/userTradeAccess.service.ts @@ -25,7 +25,27 @@ export interface MyTradeAccess { directions: TradeDirection[]; } +export interface StaffUser { + id: string; + /** Localized jsonb on iam.users — not a plain string. */ + name: { en?: string; am?: string } | null; + username: string; + email: string | null; +} + export const userTradeAccessService = { + /** + * Employees to assign scopes to. Served by the freight API rather than IAM's + * `/users/filter`, which 400s on its own pagination params (its @Query() DTO + * omits skip/take/orderBy while the global whitelist pipe rejects them). + */ + employees: async (): Promise<{ items: StaffUser[] }> => + ( + await client.get("/staff/users", { + params: { userType: "employee", pageSize: 100 }, + }) + ).data, + /** All configured per-user scopes (admin only). */ list: async (): Promise => (await client.get("/user-trade-access")).data, From 488c2465befef628f2269554b4010adb9e5089ba Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 19:16:28 +0000 Subject: [PATCH 06/10] feat: add per-ton cargo loading limits and update related services --- ...90000000000-AddCargoTypeTonsPerWagonMap.ts | 25 ++++++ .../bookings/booking-pricing.service.ts | 8 +- .../rule-engine/dto/create-cargo-type.dto.ts | 13 +++ .../rule-engine/entities/cargo-type.entity.ts | 11 +++ .../modules/rule-engine/rule-engine.module.ts | 4 + .../services/cargo-types.service.ts | 90 ++++++++++++++++++- .../train-scheduling/booking-batch.service.ts | 21 ++++- .../train-scheduling/fleet-plan.util.ts | 8 +- .../train-capacity.util.spec.ts | 59 ++++++++++++ .../train-scheduling/train-capacity.util.ts | 90 +++++++++++++++++++ .../train-scheduling.service.ts | 7 +- .../train-scheduling/wagon-plan-flex.util.ts | 11 +-- .../train-scheduling/wagon-plan.util.ts | 36 +++++++- .../src/pages/ruleEngine/CargoTypesPage.tsx | 32 ++++++- 14 files changed, 394 insertions(+), 21 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts diff --git a/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts new file mode 100644 index 000000000..04fb6c1aa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3190000000000-AddCargoTypeTonsPerWagonMap.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * PER_TON (bulk) cargo can have a loading limit BELOW the wagon's rated + * capacity: sugar rides 50T on a 70T wagon (density/stowage/policy), so 200T + * needs 4 wagons, not the 3 that raw capacity implies. Stored as a jsonb map + * { [wagonTypeId]: maxTons } on cargo_types — the PER_TON mirror of + * items_per_wagon_map. Unset (or no key) means the wagon's full rated capacity, + * so existing cargo types keep their current behaviour with no backfill. + */ +export class AddCargoTypeTonsPerWagonMap3190000000000 implements MigrationInterface { + name = 'AddCargoTypeTonsPerWagonMap3190000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" ADD COLUMN IF NOT EXISTS "tons_per_wagon_map" jsonb`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."cargo_types" DROP COLUMN IF EXISTS "tons_per_wagon_map"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index b44c1c5e0..9bbe6bed5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -16,7 +16,7 @@ import { containersPerWagonForSize, wagonsPerUnitForSize, } from '../rule-engine/container-type.util'; -import { bulkItemWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; +import { bulkWagonsForAllowedTypes } from '../train-scheduling/train-capacity.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -1190,8 +1190,10 @@ export class BookingPricingService { // Break-bulk (PER_ITEM): `tons` above is the item count; size by // indivisible items instead of pretending the count is tonnage. Best // count across allowed wagon types, each capped by its items-fit. - const byItems = bulkItemWagonsForAllowedTypes(booking, cargo, capacity); - if (byItems > 0) return byItems; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a + // 70T wagon) — 200T then prices 4 wagons, not 3. + const byWagons = bulkWagonsForAllowedTypes(booking, cargo, capacity); + if (byWagons > 0) return byWagons; return Math.max(1, Math.ceil(tons / capacity)); } catch { return null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 6d9cb82a2..eefc560e8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -44,6 +44,19 @@ export class CreateCargoTypeDto { @IsObject() itemsPerWagonMap?: Record | null; + @ApiPropertyOptional({ + description: + 'PER_TON cargo only: the most tons of this cargo one wagon may carry, keyed by ' + + 'wagon-type id (e.g. { "": 50 } loads sugar 50T on a 70T wagon, so 200T ' + + 'takes 4 wagons). Optional — omit a wagon type to use its full rated capacity. ' + + 'Rejected when it exceeds that wagon type\'s rated capacity.', + type: 'object', + additionalProperties: { type: 'number', minimum: 0.001 }, + }) + @IsOptional() + @IsObject() + tonsPerWagonMap?: Record | null; + @ApiPropertyOptional({ default: false }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index b096613bf..e685f3a2d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -60,6 +60,17 @@ export class CargoType extends BaseEntity { @Column({ name: 'items_per_wagon_map', type: 'jsonb', nullable: true }) itemsPerWagonMap?: Record | null; + /** + * PER_TON (bulk) only: the most tons of THIS cargo that may ride one wagon of + * each allowed type, keyed by wagon-type id (e.g. sugar → { NW5: 50 } on a + * 70T wagon). Caps both the wagon count and how much each wagon is loaded, so + * 200T of sugar takes 4 wagons at 50T rather than 3 at 70T. A missing key (or + * a null map) means the wagon's full rated capacity — unlike itemsPerWagonMap + * this is optional, so cargo without a loading limit is unaffected. + */ + @Column({ name: 'tons_per_wagon_map', type: 'jsonb', nullable: true }) + tonsPerWagonMap?: Record | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 691992c54..2e1fd8090 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -68,6 +68,7 @@ import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { WagonTypesModule } from '../wagon-types/wagon-types.module'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -96,6 +97,9 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ]), // Team notifications for the priority-rule approval workflow. NotificationInboxModule, + // Rated wagon capacities — cargo types validate their per-wagon tonnage cap + // against them (a cap above the rating is a typo, not a policy). + WagonTypesModule, ], controllers: [ CargoTypesController, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 34287a023..1f25b0823 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -6,6 +6,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { In } from 'typeorm'; import { generateCode } from '../../../common/utils/generate-code.util'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -13,6 +14,7 @@ import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, @@ -27,6 +29,7 @@ export class CargoTypesService { private readonly repository: ICargoTypesRepository, @Inject(RATES_REPOSITORY) private readonly ratesRepository: IRatesRepository, + private readonly wagonTypesRepository: WagonTypesRepository, private readonly displayOrder: DisplayOrderService, ) {} @@ -75,6 +78,63 @@ export class CargoTypesService { return map; } + /** + * PER_TON (bulk) cargo may cap how many tons ride one wagon, BELOW that + * wagon's rated capacity: sugar at 50T on a 70T wagon means 200T takes 4 + * wagons, not 3. Unlike the PER_ITEM fit this is optional — an absent key + * means the full rated capacity, so existing cargo types are unaffected. + * + * A cap ABOVE the rated capacity is rejected: nobody loads 90T on a 70T + * wagon, so it is a typo, and silently clamping it would leave the config + * screen showing a number the trains never honour. (Allocation clamps too, via + * `bulkTonsPerWagon`, for caps left stale by a later wagon-type edit — this + * check cannot see those, since the cargo type is never re-saved.) + * + * Returns the map trimmed to the allowed wagon types, or null when the cargo + * is not PER_TON / nothing is capped. + */ + private async resolveTonsPerWagonMap(input: { + unitOfMeasure?: CargoUnitOfMeasure | null; + wagonTypeIds: string[]; + tonsPerWagonMap?: Record | null; + }): Promise | null> { + if (input.unitOfMeasure !== CargoUnitOfMeasure.PerTon || !input.wagonTypeIds.length) { + return null; + } + const capped = input.wagonTypeIds.filter( + (id) => input.tonsPerWagonMap?.[id] !== undefined && input.tonsPerWagonMap[id] !== null, + ); + if (!capped.length) return null; + + const wagonTypes = await this.wagonTypesRepository.findAll({ + where: { id: In(capped) }, + }); + const capacityById = new Map( + wagonTypes.map((wt) => [wt.id, Number(wt.capacityTons) || 0]), + ); + + const map: Record = {}; + for (const wagonTypeId of capped) { + const tons = Number(input.tonsPerWagonMap?.[wagonTypeId]); + if (!Number.isFinite(tons) || tons <= 0) { + throw new BadRequestException( + `tonsPerWagonMap for wagon type ${wagonTypeId} must be a number greater than 0`, + ); + } + const capacity = capacityById.get(wagonTypeId); + if (capacity === undefined) { + throw new BadRequestException(`Wagon type ${wagonTypeId} not found`); + } + if (capacity > 0 && tons > capacity) { + throw new BadRequestException( + `Max tons per wagon (${tons}T) exceeds wagon type ${wagonTypeId} rated capacity ${capacity}T`, + ); + } + map[wagonTypeId] = tons; + } + return map; + } + /** Create a new cargo type. */ async create(dto: CreateCargoTypeDto): Promise { const code = generateCode(dto.cargoTypeName); @@ -90,6 +150,12 @@ export class CargoTypesService { insertAfterId: dto.insertAfterId, }); + const tonsPerWagonMap = await this.resolveTonsPerWagonMap({ + unitOfMeasure: dto.unitOfMeasure ?? null, + wagonTypeIds: dto.wagonTypeIds ?? [], + tonsPerWagonMap: dto.tonsPerWagonMap, + }); + return this.repository.create({ code, cargoTypeName: dto.cargoTypeName, @@ -104,6 +170,7 @@ export class CargoTypesService { wagonTypeIds: dto.wagonTypeIds ?? [], itemsPerWagonMap: dto.itemsPerWagonMap, }), + tonsPerWagonMap, displayOrder, }); } @@ -116,12 +183,32 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const { wagonTypeIds, itemsPerWagonMap, insertAfterId: _insertAfterId, ...columns } = dto; + const { + wagonTypeIds, + itemsPerWagonMap, + tonsPerWagonMap, + insertAfterId: _insertAfterId, + ...columns + } = dto; // Re-validate the fit map whenever anything it depends on moves — a partial // update merges with the stored values so e.g. adding a wagon type without // its fit still 400s. Untouched fields leave the stored map alone. const touchesItemsFit = wagonTypeIds !== undefined || itemsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; + // Same merge rule for the tonnage cap: re-resolve whenever the uom, the + // allowed wagon types, or the caps themselves move, so a wagon type added + // without a cap keeps its full rated capacity and a uom flip drops stale caps. + const touchesTonsCap = + wagonTypeIds !== undefined || tonsPerWagonMap !== undefined || dto.unitOfMeasure !== undefined; + const resolvedTonsPerWagonMap = touchesTonsCap + ? await this.resolveTonsPerWagonMap({ + unitOfMeasure: + dto.unitOfMeasure !== undefined ? dto.unitOfMeasure : existing.unitOfMeasure, + wagonTypeIds: wagonTypeIds ?? (existing.wagonTypes ?? []).map((wt) => wt.id), + tonsPerWagonMap: + tonsPerWagonMap !== undefined ? tonsPerWagonMap : existing.tonsPerWagonMap, + }) + : undefined; const updated = await this.repository.update(id, { ...columns, ...(wagonTypeIds @@ -138,6 +225,7 @@ export class CargoTypesService { }), } : {}), + ...(touchesTonsCap ? { tonsPerWagonMap: resolvedTonsPerWagonMap } : {}), }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); // A uom flip renames how existing rates bill (PER_TON ↔ PER_ITEM name the diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 33730b5ae..44f7c93b5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -71,6 +71,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, @@ -4100,8 +4101,15 @@ export class BookingBatchService implements OnModuleInit { const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; const cargoTons = bookingCargoTons(booking); + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon), so divide by the cap where one is configured for this type. + const tonsPerWagon = bulkTonsPerWagon( + booking.cargoType, + booking.cargoType?.wagonTypes?.[0]?.id, + capacityTons, + ); const byWeight = - cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; + cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items can need more wagons than raw // tonnage suggests (floor items-per-wagon loses the fractional capacity). @@ -4169,6 +4177,13 @@ export class BookingBatchService implements OnModuleInit { .filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0) .map((o) => { const wagonTypeId = o.wagonTypeId as string; + // Each type sized on its OWN per-wagon tonnage cap, not just its rating + // — a type capped lower swallows less per wagon. + const tonsPerWagon = bulkTonsPerWagon( + booking.cargoType, + wagonTypeId, + o.dims.capacityTons, + ); const wagonsIfAlone = Math.max( 1, bulkItemWagonsRequired( @@ -4176,8 +4191,8 @@ export class BookingBatchService implements OnModuleInit { o.dims.capacityTons, bulkItemsFitFor(booking.cargoType, wagonTypeId), ) || - (o.dims.capacityTons > 0 - ? Math.ceil(bookingCargoTons(booking) / o.dims.capacityTons) + (tonsPerWagon > 0 + ? Math.ceil(bookingCargoTons(booking) / tonsPerWagon) : total), ); return { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index bea01af0a..2769c212b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,4 +1,4 @@ -import { bookingCargoTons, bulkItemWagonsForAllowedTypes } from './train-capacity.util'; +import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -56,8 +56,10 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n // holds the item count there, not tons. No wagon type is fixed yet, so use // the best count across the cargo's allowed types (per-type items-fit // respected); falls back to `capacity` when the relation isn't loaded. - const byItems = bulkItemWagonsForAllowedTypes(booking, booking.cargoType, capacity); - if (byItems > 0) return byItems; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon), so tonnage divides by that cap, not by raw capacity. + const byWagons = bulkWagonsForAllowedTypes(booking, booking.cargoType, capacity); + if (byWagons > 0) return byWagons; const weight = Number(booking.cargoTotalWeightVgm ?? 0); return Math.max(1, Math.ceil(weight / capacity)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index ead3a4e8b..ed476a6e1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -4,6 +4,10 @@ import { bookingTrainLengthMeters, bulkItemWagonsForAllowedTypes, bulkItemWagonsRequired, + bulkTonsPerWagon, + bulkTonWagonsForAllowedTypes, + bulkTonWagonsRequired, + bulkWagonsForAllowedTypes, consistUsage, consistViolations, deriveTrainCapacityFromLocomotive, @@ -135,6 +139,61 @@ describe('train-capacity.util', () => { }); }); + describe('bulkTonsPerWagon / bulkTonWagonsRequired (PER_TON loading cap)', () => { + // Sugar is loaded 50T per wagon even on a 70T wagon. + const sugar = { wagonTypes: [{ id: 'nw5', capacityTons: 70 }], tonsPerWagonMap: { nw5: 50 } }; + const bulk = (tons: number) => ({ freightType: 'BULK', cargoTotalWeightVgm: tons }); + + it('uses the configured cap instead of the rated capacity', () => { + expect(bulkTonsPerWagon(sugar, 'nw5', 70)).toBe(50); + }); + + it('falls back to rated capacity when the cargo type caps nothing', () => { + expect(bulkTonsPerWagon(null, 'nw5', 70)).toBe(70); + expect(bulkTonsPerWagon({ wagonTypes: [] }, 'nw5', 70)).toBe(70); + expect(bulkTonsPerWagon({ tonsPerWagonMap: { other: 50 } }, 'nw5', 70)).toBe(70); + }); + + it('clamps a stale cap that now exceeds the rating (wagon type edited down)', () => { + // Saved when NW5 was rated 70T; the type was later re-rated to 45T. + expect(bulkTonsPerWagon(sugar, 'nw5', 45)).toBe(45); + }); + + it('sizes 200T of capped sugar at 4 wagons, not the 3 raw capacity implies', () => { + expect(bulkTonWagonsRequired(bulk(200), sugar, 'nw5', 70)).toBe(4); + // Same booking, no cap → the old 3-wagon answer. + expect(bulkTonWagonsRequired(bulk(200), null, 'nw5', 70)).toBe(3); + }); + + it('picks the fewest-wagon allowed type, each on its own cap', () => { + const cargoType = { + wagonTypes: [ + { id: 'nw5', capacityTons: 70 }, + { id: 'nw7', capacityTons: 80 }, + ], + tonsPerWagonMap: { nw5: 50 }, + }; + // NW5 capped 50 → 4 wagons; NW7 uncapped 80 → 3 wagons. Best = 3. + expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3); + }); + + it('routes PER_ITEM and PER_TON through one call', () => { + expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4); + // PER_ITEM still wins where an item count is present. + const cars = { + wagonTypes: [{ id: 'nw5', capacityTons: 70 }], + itemsPerWagonMap: { nw5: 4 }, + }; + expect( + bulkWagonsForAllowedTypes( + { freightType: 'BULK', cargoTotalWeightVgm: 50, bulkTotalWeightTons: 1000 }, + cars, + 70, + ), + ).toBe(17); + }); + }); + describe('bookingCargoTons (break-bulk weight preference)', () => { it('prefers bulkTotalWeightTons over the item-count VGM column', () => { expect( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 867da564b..63feb6e8c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -152,8 +152,98 @@ export function bulkItemWagonsRequired( type ItemFitCargoType = { wagonTypes?: Array<{ id: string; capacityTons?: number | string | null }> | null; itemsPerWagonMap?: Record | null; + tonsPerWagonMap?: Record | null; } | null; +/** + * Tons of THIS cargo one wagon of this type may carry: the cargo type's + * configured loading limit when set, else the wagon's full rated capacity. + * Sugar capped at 50T rides 50T on a 70T wagon, so 200T needs 4 wagons and each + * is loaded to 50 — both the count and the fill follow from this one number. + * + * The configured cap is CLAMPED to the rated capacity rather than trusted: the + * cargo-types service rejects a cap above capacity at save time, but a wagon + * type edited DOWN afterwards would leave a stale cap that overloads the wagon. + * Clamping here means no call site can ever load past the physical rating. + */ +export function bulkTonsPerWagon( + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const capacity = num(capacityTons); + const cap = wagonTypeId ? num(cargoType?.tonsPerWagonMap?.[wagonTypeId]) : 0; + if (!(cap > 0)) return capacity; + return capacity > 0 ? Math.min(cap, capacity) : cap; +} + +/** + * Wagons a PER_TON bulk booking needs on one wagon type, respecting the cargo + * type's per-wagon loading limit: 200T of sugar capped at 50T → 4 wagons even + * though the wagon is rated 70T. Returns 0 when there is no tonnage or no + * usable per-wagon figure, so callers can fall back as before. + */ +export function bulkTonWagonsRequired( + booking: Parameters[0], + cargoType: ItemFitCargoType | undefined, + wagonTypeId: string | null | undefined, + capacityTons: number | string | null | undefined, +): number { + const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons); + const tons = bookingCargoTons(booking); + if (!(perWagon > 0) || !(tons > 0)) return 0; + return Math.max(1, Math.ceil(tons / perWagon)); +} + +/** + * Best (fewest-wagon) PER_TON count across the cargo type's allowed wagon + * types, each sized on its OWN loading limit — the tonnage twin of + * {@link bulkItemWagonsForAllowedTypes}, for the call sites that have no single + * wagon type fixed yet. Falls back to `fallbackCapacityTons` when the cargo + * type has no usable allowed types. + */ +export function bulkTonWagonsForAllowedTypes( + booking: Parameters[0], + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + const allowed = (cargoType?.wagonTypes ?? []).filter((wt) => num(wt.capacityTons) > 0); + if (!allowed.length) { + return bulkTonWagonsRequired(booking, cargoType, null, fallbackCapacityTons); + } + let best = 0; + for (const wagonType of allowed) { + const wagons = bulkTonWagonsRequired( + booking, + cargoType, + wagonType.id, + wagonType.capacityTons, + ); + if (wagons > 0 && (best === 0 || wagons < best)) best = wagons; + } + return best; +} + +/** + * Wagons a BULK booking needs, whichever way its cargo is measured: PER_ITEM + * sizes by indivisible items, everything else by tonnage under the cargo type's + * per-wagon loading limit. One call so no site has to remember both paths. + */ +export function bulkWagonsForAllowedTypes( + booking: Parameters[0] & { + freightType?: string | null; + cargoTotalWeightVgm?: number | string | null; + bulkTotalWeightTons?: number | string | null; + }, + cargoType: ItemFitCargoType | undefined, + fallbackCapacityTons: number, +): number { + return ( + bulkItemWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) || + bulkTonWagonsForAllowedTypes(booking, cargoType, fallbackCapacityTons) + ); +} + /** Configured whole-items fit of one wagon type for a cargo type; null if unset. */ export function bulkItemsFitFor( cargoType: ItemFitCargoType | undefined, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 495feedf0..db8236927 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -138,6 +138,7 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, trainSetLocomotiveLimits, @@ -7347,8 +7348,10 @@ export class TrainSchedulingService { ? Math.ceil(booking.wagonsRequired) : 0; const byLength = containerWagonsForLines(booking.bookingContainers ?? []); - const byWeight = - cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0; + // PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T + // wagon) — more wagons for the same cargo, so more tare to pull. + const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons); + const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0; // Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw // tonnage suggests — their tare must be pulled too (batch dimsFor parity). const byItems = bulkItemWagonsRequired( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 0810b3369..ef1e741be 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -5,7 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, - bulkItemWagonsForAllowedTypes, + bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { sortBookingsForScheduling, @@ -122,10 +122,11 @@ const shortageFor = ( ? Math.max( 1, // Break-bulk (PER_ITEM) sizes by indivisible items (items-fit map - // respected); PER_TON falls through to tonnage over the largest - // candidate. bookingCargoTons, not raw VGM — for PER_ITEM that - // column is the item count, not tons. - bulkItemWagonsForAllowedTypes( + // respected); PER_TON divides by its per-wagon tonnage cap where one + // is configured, else the largest candidate's rating. + // bookingCargoTons, not raw VGM — for PER_ITEM that column is the + // item count, not tons. + bulkWagonsForAllowedTypes( booking, booking.cargoType, Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index ef3089925..9720e46c4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -7,6 +7,8 @@ import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, + bulkTonsPerWagon, + bulkTonWagonsRequired, consistViolations, } from './train-capacity.util'; @@ -186,15 +188,29 @@ export function buildBulkWagonPlan( bulkItemWagonsRequired(b, capacity, bulkItemsFitFor(b.cargoType, wagonType.id)), ); const itemSlots = itemSlotsByBooking.reduce((sum, n) => sum + n, 0); + + // PER_TON cargo with a per-wagon tonnage cap (sugar 50T on a 70T wagon) can't + // pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs + // 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on + // their own cap; only genuinely uncapped tonnage pools at rated capacity. + const cappedTonSlotsByBooking = bookings.map((b, i) => + itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity + ? 0 + : bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity), + ); + const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0); + const totalWeight = roundTons( bookings.reduce( (sum, b, i) => - itemSlotsByBooking[i] > 0 ? sum : sum + Number(b.cargoTotalWeightVgm ?? 0), + itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0 + ? sum + : sum + Number(b.cargoTotalWeightVgm ?? 0), 0, ), ); const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; - const slots = Math.max(1, tonSlots + itemSlots); + const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ sequenceNo: index + 1, @@ -315,6 +331,7 @@ function allocateBookingsToSlots( // bookingCargoTons, not the raw VGM column: for break-bulk (PER_ITEM) // bookings that column is an item COUNT, not tons. remainingWeightTons: roundTons(bookingCargoTons(booking)), + cargoType: booking.cargoType, })); let bookingIndex = 0; @@ -326,8 +343,15 @@ function allocateBookingsToSlots( while (wagonRemaining > 0 && bookingIndex < remaining.length) { const booking = remaining[bookingIndex]; + // A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well + // as the wagon count — the plan reserved a wagon per capped chunk, so + // pouring rated capacity into it would leave the last wagon empty. + const takeCap = Math.min( + wagonRemaining, + bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons), + ); const allocatedWeightTons = roundTons( - Math.min(wagonRemaining, booking.remainingWeightTons), + Math.min(takeCap, booking.remainingWeightTons), ); if (allocatedWeightTons <= 0) { @@ -350,6 +374,12 @@ function allocateBookingsToSlots( if (booking.remainingWeightTons <= 0) { bookingIndex += 1; + } else if (allocatedWeightTons >= takeCap) { + // The cap stopped this wagon short of its rating and the booking has + // more to load. The leftover room is NOT free: `buildBulkWagonPlan` + // already reserved a wagon for the rest, so backfilling another booking + // here would double-book the consist. Close the wagon. + break; } } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx index e37db97cc..57c239885 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/CargoTypesPage.tsx @@ -60,12 +60,16 @@ interface CargoNode extends RuleEngineRecord { wagonTypes?: { id: string; code?: string; name?: string }[]; /** PER_ITEM only: whole items that physically fit one wagon, keyed by wagon-type id. */ itemsPerWagonMap?: Record | null; + /** PER_TON only: max tons of this cargo per wagon, keyed by wagon-type id. */ + tonsPerWagonMap?: Record | null; isActive?: boolean; displayOrder?: number; } /** Form-value prefix for the per-wagon-type items-fit inputs (PER_ITEM cargo). */ const ITEMS_FIT_PREFIX = "itemsFit__"; +/** Form-value prefix for the per-wagon-type tonnage-cap inputs (PER_TON cargo). */ +const TONS_CAP_PREFIX = "tonsCap__"; const str = (v: unknown): string => (v == null ? "" : String(v)); const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0); @@ -159,8 +163,26 @@ const CargoTypesPage = () => { getInitialValue: (record) => (record as CargoNode).itemsPerWagonMap?.[opt.value], })); + // PER_TON cargo: an OPTIONAL "max tons per wagon" per selected wagon type — + // how much of this commodity actually rides one wagon, which can be less + // than its rating (sugar 50T on a 70T wagon, so 200T takes 4 wagons not 3). + // Left blank the wagon's full rated capacity applies, so existing cargo is + // unaffected; the API rejects a value above the rating. + const tonsCapFields: FormFieldDef[] = (wagonTypeOptions ?? []).map((opt) => ({ + name: `${TONS_CAP_PREFIX}${opt.value}`, + label: `Max tons per ${opt.label} wagon`, + type: "number", + optional: true, + placeholder: "Blank = full wagon capacity", + showIf: (values) => + values.unitOfMeasure === "PER_TON" && + Array.isArray(values.wagonTypeIds) && + (values.wagonTypeIds as string[]).includes(opt.value), + getInitialValue: (record) => + (record as CargoNode).tonsPerWagonMap?.[opt.value], + })); const wagonTypesAt = base.findIndex((field) => field.name === "wagonTypeIds"); - base.splice(wagonTypesAt + 1, 0, ...fitFields); + base.splice(wagonTypesAt + 1, 0, ...fitFields, ...tonsCapFields); return base; }, [wagonTypeOptions]); @@ -230,14 +252,22 @@ const CargoTypesPage = () => { // none are visible (not PER_ITEM) so an update clears stale fits. const payload: Record = {}; const itemsPerWagonMap: Record = {}; + const tonsPerWagonMap: Record = {}; for (const [key, value] of Object.entries(values)) { if (key.startsWith(ITEMS_FIT_PREFIX)) { itemsPerWagonMap[key.slice(ITEMS_FIT_PREFIX.length)] = Number(value); + } else if (key.startsWith(TONS_CAP_PREFIX)) { + // Blank means "no cap" (use the full rated capacity), so an empty input + // must stay OUT of the map — sending 0 would be a zero-ton wagon. + if (value !== "" && value !== null && value !== undefined) { + tonsPerWagonMap[key.slice(TONS_CAP_PREFIX.length)] = Number(value); + } } else { payload[key] = value; } } payload.itemsPerWagonMap = Object.keys(itemsPerWagonMap).length ? itemsPerWagonMap : null; + payload.tonsPerWagonMap = Object.keys(tonsPerWagonMap).length ? tonsPerWagonMap : null; // Add always attaches to the page we're on; edit keeps the node's parent. if (formMode?.kind === "create" && current) { payload.parentGroupId = current.id; From e68bdb7a1a9c9268473bddfc28b150d7018168de Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 21:06:59 +0000 Subject: [PATCH 07/10] Enhance overview and train scheduling features - Updated OverviewContractsTabPanel to include a new donut chart for freight type distribution. - Modified OverviewOperationsTabPanel to improve data visualization with additional charts and refactored data handling. - Introduced CreateScheduleWindowFields component for configuring booking windows in train scheduling. - Added new API endpoints for allocation candidates and booking allocation in trainScheduling.service. - Enhanced BookingRequestsPage to support allocation of paid bookings with a modal for selecting alternative dates. - Updated QUERY_KEYS and URLS constants to accommodate new operations and features. - Improved type definitions for overview and train scheduling to support new functionalities. --- ...00000000000-AddScheduleWindowRuleCustom.ts | 28 ++ .../overview/dto/overview-response.dto.ts | 2 + .../overview/dto/overview-tab-response.dto.ts | 30 ++ .../modules/overview/overview.controller.ts | 6 +- .../src/modules/overview/overview.module.ts | 2 + .../modules/overview/overview.repository.ts | 172 +++++++- .../src/modules/overview/overview.service.ts | 24 +- .../entities/train-schedule.entity.ts | 9 + .../train-scheduling/booking-batch.service.ts | 130 +++++++ .../booking-notifier.service.ts | 13 + .../create-container-train-schedule.dto.ts | 113 ++++++ .../train-scheduling.controller.ts | 26 ++ .../train-scheduling.service.spec.ts | 45 +++ .../train-scheduling.service.ts | 125 ++++-- .../overview/OverviewStackedBarChart.tsx | 82 ++++ .../overview/OverviewTabContent.tsx | 2 +- .../tabs/OverviewBookingsTabPanel.tsx | 24 +- .../tabs/OverviewContractsTabPanel.tsx | 21 +- .../tabs/OverviewOperationsTabPanel.tsx | 135 ++++++- .../CreateScheduleWindowFields.tsx | 366 ++++++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 3 +- .../backoffice/src/constants/URLS.ts | 4 + .../backoffice/src/hooks/useOverview.ts | 6 +- .../pages/bookings/BookingRequestsPage.tsx | 162 +++++++- .../TrainScheduleV2ListPage.tsx | 49 +++ .../src/services/overview.service.ts | 8 +- .../src/services/trainScheduling.service.ts | 20 + .../backoffice/src/types/overview.ts | 2 + .../backoffice/src/types/trainScheduling.ts | 32 ++ packages/types/src/freight/overview.ts | 21 + 30 files changed, 1580 insertions(+), 82 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx diff --git a/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts new file mode 100644 index 000000000..821c67135 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Marks a train schedule whose booking-window rule was configured by staff at + * creation rather than inherited from the live global rules. + * + * Without this flag `restampPendingWindows` — which re-derives EVERY still + * PRE_WINDOW schedule from the current global config after a global-rules edit — + * would silently overwrite those hand-picked settings, which is precisely what + * the per-schedule configuration exists to prevent. + * + * Defaults false, so every existing schedule keeps following the global rules. + */ +export class AddScheduleWindowRuleCustom3200000000000 implements MigrationInterface { + name = 'AddScheduleWindowRuleCustom3200000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."train_schedules" ADD COLUMN IF NOT EXISTS "window_rule_custom" boolean NOT NULL DEFAULT false`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index ab914deff..981ff35dd 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -21,6 +21,8 @@ export class OverviewOperationsKpisDto { @ApiProperty() wagonsAvailable!: number; @ApiProperty() containersInTransit!: number; @ApiProperty() cargoesLoaded!: number; + @ApiProperty() schedulesUpcoming!: number; + @ApiProperty() dispatchedToday!: number; } export class OverviewCustomerKpisDto { diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts index d4e5c2782..7c60a91a6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -104,10 +104,40 @@ export class OverviewBillingTabDto { generatedAt!: string; } +export class OverviewDirectionTrendPointDto { + @ApiProperty({ example: '2026-08-01' }) date!: string; + @ApiProperty() importCount!: number; + @ApiProperty() exportCount!: number; + @ApiProperty() domesticCount!: number; +} + +export class OverviewTonnagePointDto { + @ApiProperty() label!: string; + @ApiProperty() tons!: number; +} + export class OverviewOperationsTabDto { @ApiProperty({ type: OverviewOperationsKpisDto }) kpis!: OverviewOperationsKpisDto; + @ApiProperty({ type: [OverviewDirectionTrendPointDto] }) + departureTrend!: OverviewDirectionTrendPointDto[]; + + @ApiProperty({ type: [OverviewStatusCountDto] }) + scheduleStatusBreakdown!: OverviewStatusCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + wagonsByType!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + wagonsByYard!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewLabelCountDto] }) + containersBySize!: OverviewLabelCountDto[]; + + @ApiProperty({ type: [OverviewTonnagePointDto] }) + cargoTonnageByType!: OverviewTonnagePointDto[]; + @ApiProperty({ type: [OverviewStatusCountDto] }) trainStatusBreakdown!: OverviewStatusCountDto[]; diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index 4e1ecc6d6..661350f49 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -99,8 +99,10 @@ export class OverviewController { @BookingView() @ApiOperation({ summary: 'Operations tab metrics and charts' }) @ApiOkResponse({ type: OverviewOperationsTabDto }) - getOperationsTab(): Promise { - return this.overviewService.getOperationsTab(); + getOperationsTab( + @Query() query: OverviewQueryDto, + ): Promise { + return this.overviewService.getOperationsTab(query.range ?? '30d'); } @Get('customers') diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index ff19bb801..a20f75ed2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -9,6 +9,7 @@ import { Container } from "../container-management/entities/container.entity"; import { Company } from "../companies/entities/company.entity"; import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { Train } from "../trains/entities/train.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; @@ -23,6 +24,7 @@ import { OverviewService } from "./overview.service"; PaymentEntity, Company, Train, + TrainSchedule, Wagon, Container, Cargo, diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index a8957d760..c75510c0e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -11,7 +11,12 @@ import { Cargo } from "../cargoes/entities/cargoes.entity"; import { Container } from "../container-management/entities/container.entity"; import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { Yard } from "../rule-engine/entities/yard.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { Train } from "../trains/entities/train.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, @@ -83,6 +88,8 @@ export class OverviewRepository { private readonly companyRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, + @InjectRepository(TrainSchedule) + private readonly trainScheduleRepository: Repository, @InjectRepository(Wagon) private readonly wagonRepository: Repository, @InjectRepository(Container) @@ -146,9 +153,17 @@ export class OverviewRepository { wagonsAvailable: number; containersInTransit: number; cargoesLoaded: number; + schedulesUpcoming: number; + dispatchedToday: number; }> { - const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = - await Promise.all([ + const [ + trainsActive, + wagonsAvailable, + containersInTransit, + cargoesLoaded, + schedulesUpcoming, + dispatchedToday, + ] = await Promise.all([ this.trainRepository .createQueryBuilder("train") .where("train.deleted_at IS NULL") @@ -178,6 +193,22 @@ export class OverviewRepository { statuses: ["LOADED", "IN_TRANSIT"], }) .getCount(), + this.trainScheduleRepository + .createQueryBuilder("schedule") + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status = :status", { + status: Freight.TrainScheduleStatus.Scheduled, + }) + .andWhere("schedule.scheduled_departure_date >= CURRENT_DATE") + .getCount(), + this.trainScheduleRepository + .createQueryBuilder("schedule") + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status = :status", { + status: Freight.TrainScheduleStatus.Dispatched, + }) + .andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE") + .getCount(), ]); return { @@ -185,6 +216,8 @@ export class OverviewRepository { wagonsAvailable, containersInTransit, cargoesLoaded, + schedulesUpcoming, + dispatchedToday, }; } @@ -533,6 +566,141 @@ export class OverviewRepository { return this.statusBreakdown(this.cargoRepository, "cargo"); } + async getScheduleStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.trainScheduleRepository, "schedule"); + } + + /** Scheduled departures per day over the range, split by trade direction. */ + async getDepartureTrend(days: number): Promise< + { + date: string; + importCount: number; + exportCount: number; + domesticCount: number; + }[] + > { + const rows = await this.trainScheduleRepository + .createQueryBuilder("schedule") + .select( + `to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`, + "date", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`, + "importCount", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`, + "exportCount", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`, + "domesticCount", + ) + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status != :draft", { + draft: Freight.TrainScheduleStatus.Draft, + }) + .andWhere( + `schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere( + `schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`, + { ahead: 8 }, + ) + .groupBy("schedule.scheduled_departure_date::date") + .orderBy("schedule.scheduled_departure_date::date", "ASC") + .getRawMany<{ + date: string; + importCount: string; + exportCount: string; + domesticCount: string; + }>(); + + return rows.map((row) => ({ + date: row.date, + importCount: Number(row.importCount), + exportCount: Number(row.exportCount), + domesticCount: Number(row.domesticCount), + })); + } + + async getWagonsByType(): Promise<{ label: string; count: number }[]> { + const rows = await this.wagonRepository + .createQueryBuilder("wagon") + .leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id") + .select(`COALESCE(wagon_type.name, 'Unknown')`, "label") + .addSelect("COUNT(*)::int", "count") + .where("wagon.deleted_at IS NULL") + .groupBy("wagon_type.name") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + async getWagonsByYard(limit: number): Promise< + { label: string; count: number }[] + > { + const rows = await this.wagonRepository + .createQueryBuilder("wagon") + .innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id") + .select("yard.label", "label") + .addSelect("COUNT(*)::int", "count") + .where("wagon.deleted_at IS NULL") + .groupBy("yard.label") + .orderBy("count", "DESC") + .limit(limit) + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + async getContainersBySize(): Promise<{ label: string; count: number }[]> { + const rows = await this.containerRepository + .createQueryBuilder("container") + .leftJoin( + ContainerType, + "container_type", + "container_type.id = container.container_type_id", + ) + .select( + `COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`, + "label", + ) + .addSelect("COUNT(*)::int", "count") + .where("container.deleted_at IS NULL") + .groupBy("container_type.size_ft") + .addGroupBy("container_type.code") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + /** Total cargo weight (tons) grouped by cargo type, heaviest first. */ + async getCargoTonnageByType(limit: number): Promise< + { label: string; tons: number }[] + > { + const rows = await this.cargoRepository + .createQueryBuilder("cargo") + .leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id") + .select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label") + .addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons") + .where("cargo.deleted_at IS NULL") + .groupBy("cargo_type.cargo_type_name") + .orderBy("tons", "DESC") + .limit(limit) + .getRawMany<{ label: string; tons: string }>(); + + return rows + .map((row) => ({ label: row.label, tons: Number(row.tons) })) + .filter((row) => row.tons > 0); + } + private async statusBreakdown( repository: Repository, alias: string, diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 4b1f5d6bf..399bf4f72 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -202,15 +202,31 @@ export class OverviewService { }; } - async getOperationsTab(): Promise { + async getOperationsTab( + range: OverviewRangeQuery = '30d', + ): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + const [ kpis, + departureTrend, + scheduleStatusBreakdown, + wagonsByType, + wagonsByYard, + containersBySize, + cargoTonnageByType, trainStatusBreakdown, wagonStatusBreakdown, containerStatusBreakdown, cargoStatusBreakdown, ] = await Promise.all([ this.overviewRepository.getOperationsKpis(), + this.overviewRepository.getDepartureTrend(days), + this.overviewRepository.getScheduleStatusBreakdown(), + this.overviewRepository.getWagonsByType(), + this.overviewRepository.getWagonsByYard(8), + this.overviewRepository.getContainersBySize(), + this.overviewRepository.getCargoTonnageByType(8), this.overviewRepository.getTrainStatusBreakdown(), this.overviewRepository.getWagonStatusBreakdown(), this.overviewRepository.getContainerStatusBreakdown(), @@ -219,6 +235,12 @@ export class OverviewService { return { kpis, + departureTrend, + scheduleStatusBreakdown, + wagonsByType, + wagonsByYard, + containersBySize, + cargoTonnageByType, trainStatusBreakdown, wagonStatusBreakdown, containerStatusBreakdown, diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 0581f6000..5730120e9 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -161,6 +161,15 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true }) rulePaymentWindowMinutes?: number | null; + /** + * Staff configured this schedule's booking window by hand (at creation or via + * the per-schedule override) instead of inheriting the live global rules. + * `restampPendingWindows` skips these, so a later global-rules edit cannot + * silently overwrite the hand-picked settings. + */ + @Column({ name: 'window_rule_custom', type: 'boolean', default: false }) + windowRuleCustom!: boolean; + @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) ruleImportWindowLeadDays?: number | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 44f7c93b5..3e1af07b5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -151,6 +151,14 @@ export interface ExportTrainOption { }>; } +/** A train a paid-unallocated booking can board (route + capacity verified). */ +export interface AllocationCandidate { + id: string; + reference: string | null; + direction: string | null; + scheduledDepartureDate: Date; +} + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -3040,6 +3048,104 @@ export class BookingBatchService implements OnModuleInit { this.notifyBoardChanged(newScheduleId, "booking_moved"); } + /** + * Trains a paid-but-unallocated booking can board right now: OPEN window, + * future departure, route covers the booking's leg, and remaining corridor + * capacity fits it. Split by the booking's own scheduled day so the UI can + * offer one-click same-day allocation vs an explicit "another date" choice. + */ + async allocationCandidates(bookingId: string): Promise<{ + sameDay: AllocationCandidate[]; + otherDays: AllocationCandidate[]; + }> { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { + bookingContainers: { containerType: true }, + // wagonTypes drives the break-bulk items-per-wagon fit — size the + // booking exactly as the intercity accept check does. + cargoType: { wagonTypes: true }, + }, + }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + const schedules = await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }); + const today = eatDay(new Date()); + const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null; + const sameDay: AllocationCandidate[] = []; + const otherDays: AllocationCandidate[] = []; + for (const s of schedules) { + if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue; + if (s.bookingWindowStatus !== "OPEN") continue; + if (s.id === booking.trainScheduleId) continue; + const stops = await this.stopsForSchedule(s); + const fromIdx = stops.indexOf(booking.originYardId); + const toIdx = stops.indexOf(booking.destinationYardId); + if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue; + // ponytail: full capacity build per candidate is heavy; the set is small + // (future OPEN trains on the booking's route) — precompute if it grows. + const cap = await this.intercityCapacity(s.id); + if (!cap) continue; + const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId); + if (!cap.budget.fits(cap.needFor(booking), leg)) continue; + const candidate: AllocationCandidate = { + id: s.id, + reference: s.reference ?? s.trainNumber ?? null, + direction: s.direction ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + }; + (eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate); + } + const byDate = (a: AllocationCandidate, b: AllocationCandidate) => + new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime(); + sameDay.sort(byDate); + otherDays.sort(byDate); + return { sameDay, otherDays }; + } + + /** + * Place a PAID booking that lost (or never got) its train: re-point via + * moveToSchedule (window/route validation + day sync), then allocate it + * immediately — payment already landed, so no new pay window opens. The + * customer gets an in-app notice when the new train departs on a different + * day than their original choice. + */ + async allocatePaid(bookingId: string, scheduleId: string): Promise { + const before = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: bookingId } }); + if (!before) throw new NotFoundException(`Booking ${bookingId} not found`); + if (before.paymentStatus !== "PAID" && before.status !== "PAID") { + throw new BadRequestException( + "Booking is not paid — use the regular scheduling flow", + ); + } + const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null; + await this.moveToSchedule(bookingId, scheduleId); + const fresh = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!fresh) return; + if (!(await this.holdIfWagonShort(scheduleId, fresh))) { + await this.allocate(scheduleId, fresh, "paid"); + } + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if ( + previousDay && + schedule?.scheduledDepartureDate && + eatDay(schedule.scheduledDepartureDate) !== previousDay + ) { + this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate); + } + } + /** * One reminder per hold, shortly before its pay deadline (the window tick * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid @@ -3526,6 +3632,16 @@ export class BookingBatchService implements OnModuleInit { } return; } + // Paid but detached from any train (staff removed it from an allocation, + // or a sweep caught it unpinned): money was taken, so it must board — it + // stays paid-unallocated for staff to place via the allocate action. + if (paid) { + this.logger.log( + `[BATCH] expire skipped for ${booking.reference} — payment landed ` + + `but no train attached; left paid-unallocated for manual placement`, + ); + return; + } // Reconcile-before-expire (only when a pay window was actually open): // no webhook arrived, so ask the gateway DIRECTLY whether the money // landed. A late capture found there is registered as SUCCEEDED and @@ -3854,12 +3970,26 @@ export class BookingBatchService implements OnModuleInit { // booking can use — don't kill it for nothing. const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge; if (!overlaps) continue; + const victimPaid = + victim.paymentStatus === "PAID" || victim.status === "PAID"; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, victim.id, manager, ); + if (victimPaid) { + // Paid bookings are never expired — money was taken, so it boards. + // Detach it so it surfaces in the paid-unallocated queue for staff + // to re-place; the settled invoice stays untouched. + await manager.getRepository(Booking).update(victim.id, { + trainScheduleId: null, + schedulingStatus: "ELIGIBLE", + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + return; + } await manager.getRepository(Booking).update(victim.id, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index b806de5ca..84d029e4f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -275,6 +275,19 @@ export class BookingNotifierService { this.inApp(b, 'Booking rescheduled', msg); } + /** + * Staff placed a paid booking onto a train departing on a DIFFERENT day than + * the customer's original choice. In-app only — staff drove the change and + * the allocation itself already notifies through the secured path. + */ + allocatedOtherDay(b: Booking, newDeparture: Date): void { + const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); + const msg = + `Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` + + `New departure date: ${when}.`; + this.inApp(b, 'Booking allocated to another date', msg); + } + /** * Booking was removed from its train during a staff reschedule (not a government * pre-empt). It returns to eligible — the customer must rebook or reschedule. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 9dfea0781..e8716eb99 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -9,9 +9,107 @@ import { IsNumber, IsOptional, IsUUID, + Max, Min, + ValidateNested, } from 'class-validator'; +/** + * Per-schedule booking-window rule chosen AT CREATION, instead of inheriting the + * live global rules. Mirrors {@link UpdateScheduleWindowRuleDto}, plus the + * booking-close offset (which the post-creation override deliberately never + * touches). Every field is optional — an omitted field falls back to the global + * value, so staff can override just the one knob they care about. + */ +export class CreateScheduleWindowRuleDto { + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ + example: 17, + description: + 'Local EAT hour the booking desk shuts each day. Equal to windowOpenHour = 24-hour desk', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowCloseHour?: number; + + @ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.0166) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ + example: 3, + description: 'Days before departure the IMPORT/DOMESTIC booking window starts', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ + example: 24, + description: 'Hours before departure the single FCFS EXPORT window opens', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; + + @ApiPropertyOptional({ + example: 180, + nullable: true, + description: + 'Minutes before departure the booking window closes; 0/null = close at departure. ' + + 'Only the offset matching the schedule direction is used (import offset for ' + + 'IMPORT/DOMESTIC, export offset for EXPORT).', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importCloseOffsetMinutes?: number | null; + + @ApiPropertyOptional({ + example: 1440, + nullable: true, + description: 'Minutes before departure an EXPORT booking window closes; 0/null = at departure', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + exportCloseOffsetMinutes?: number | null; +} + export class CreateContainerTrainScheduleDto { @ApiProperty({ format: 'uuid' }) @IsUUID() @@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto { @IsOptional() @IsBoolean() reverseWagonOrder?: boolean; + + @ApiPropertyOptional({ + type: CreateScheduleWindowRuleDto, + description: + 'Configure the booking window for THIS schedule instead of inheriting the live ' + + 'global rules. Omit to use the global rules (the default). The values sent are ' + + 'frozen onto the schedule as its rule snapshot, exactly as a post-creation ' + + 'override would. Rejected for an IMPORT/DOMESTIC train that joins an existing ' + + 'route+day group — those siblings share one window timeline, so edit the group ' + + "window instead of giving one member its own.", + }) + @IsOptional() + @ValidateNested() + @Type(() => CreateScheduleWindowRuleDto) + windowRule?: CreateScheduleWindowRuleDto; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 485a3ceea..95c6aa6f2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -842,6 +842,32 @@ export class TrainSchedulingController { return { ok: true }; } + @Get("bookings/:bookingId/allocation-candidates") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Trains a paid-unallocated booking fits, split same-day vs other days", + }) + getAllocationCandidates( + @Param("bookingId", ParseUUIDPipe) bookingId: string, + ) { + return this.bookingBatchService.allocationCandidates(bookingId); + } + + @Post("bookings/:bookingId/allocate") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Staff: place a paid booking onto a fitting train (notifies customer on date change)", + }) + async allocatePaidBooking( + @Param("bookingId", ParseUUIDPipe) bookingId: string, + @Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string, + ) { + await this.bookingBatchService.allocatePaid(bookingId, trainScheduleId); + return { ok: true }; + } + @Get("schedules/:id/checkpoints") @TrainSchedulingView() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index 406f51988..6a47f8ac8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => { ).rejects.toBeInstanceOf(BadRequestException); }); + describe('restampPendingWindows (hand-configured windows are exempt)', () => { + const future = new Date(Date.now() + 30 * 24 * 3600_000); + const update = jest.fn(); + + beforeEach(() => { + update.mockClear(); + // Global rules read + the TrainSchedule repo the restamp writes through. + dataSource.getRepository.mockImplementation((entity: unknown) => { + const name = (entity as { name?: string })?.name; + if (name === 'TrainSchedulingGlobalRules') { + return { find: jest.fn().mockResolvedValue([]) }; + } + return { update }; + }); + }); + + it('re-stamps a schedule that follows the global rules', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + { + id: 'sched-global', + direction: 'IMPORT', + scheduledDepartureDate: future, + windowRuleCustom: false, + }, + ]); + await expect(service.restampPendingWindows()).resolves.toBe(1); + expect(update).toHaveBeenCalledWith('sched-global', expect.anything()); + }); + + it('leaves a hand-configured schedule alone', async () => { + trainSchedulesRepository.findAll.mockResolvedValue([ + { + id: 'sched-custom', + direction: 'IMPORT', + scheduledDepartureDate: future, + windowRuleCustom: true, + }, + ]); + // Staff picked these times deliberately — a global-rules edit must not + // overwrite them, or the per-schedule configuration would be pointless. + await expect(service.restampPendingWindows()).resolves.toBe(0); + expect(update).not.toHaveBeenCalled(); + }); + }); + describe('getUnassignedBookings', () => { const scheduleId = 'sched-unassigned-1'; const trainSetId = 'train-set-unassigned'; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index db8236927..f8dfcd6d0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -181,6 +181,13 @@ import { const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */ +function pickDefined(source: T): Partial { + return Object.fromEntries( + Object.entries(source).filter(([, v]) => v !== undefined), + ) as Partial; +} + /** * The booking-window rule fields frozen onto a train schedule at creation (and * refreshed by restampPendingWindows for not-yet-open schedules). The board draws @@ -906,6 +913,9 @@ export class TrainSchedulingService { windowClosesAt: cap(times.windowClosesAt, t.departure), ...ruleFields, rulePaymentWindowMinutes, + // Deliberately overridden — exempt from the global re-stamp, which would + // otherwise revert this schedule the next time global rules are saved. + windowRuleCustom: true, }); } this.logger.log( @@ -1197,6 +1207,9 @@ export class TrainSchedulingService { let restamped = 0; for (const s of schedules) { if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue; + // Hand-configured windows are not "pending the global rule" — staff picked + // these times deliberately, so a global-rules edit must leave them alone. + if (s.windowRuleCustom) continue; const times = s.direction === 'EXPORT' ? computeExportWindowTimes(s.scheduledDepartureDate, cfg) @@ -1460,29 +1473,8 @@ export class TrainSchedulingService { // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens // 24h before departure (FCFS). No schedule is ever always-open now. - const windowCfg = await this.getWindowConfig(); + const globalCfg = await this.getWindowConfig(); - // Staff cannot schedule inside the lead window — there must be room for a - // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT - // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT - // lead is in hours (24h = 1 day ahead). - const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); - if (departure.getTime() < earliest.getTime()) { - const detail = - direction === 'EXPORT' - ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` - : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; - throw new BadRequestException( - `Departure ${departure.toISOString()} is inside the booking lead window; ` + - `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + - `(earliest ${earliest.toISOString()})`, - ); - } - // Freeze the rule this schedule is born with. A later global-rules edit - // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an - // already-open schedule keeps this snapshot, and the batch board draws its - // windows from it rather than the live config. - const ruleSnapshot = windowRuleSnapshot(windowCfg); // Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists // on this origin + destination + EAT departure day, this new train JOINS // its group and adopts the group's shared window timeline (open/close + @@ -1506,6 +1498,77 @@ export class TrainSchedulingService { route.destinationYardId, departure, ); + + // Per-schedule window rule chosen at creation. Refused for a train that + // JOINS an existing route+day group: the group shares ONE window timeline, + // so a joining train adopts the anchor's times verbatim and its own + // settings would be silently discarded. Staff edit the group's window + // instead (Booking window settings, which fans out to every sibling). + if (dto.windowRule && groupAnchor) { + throw new BadRequestException( + 'This train joins an existing booking group (same route and departure day), ' + + 'which shares one booking window across all its trains. Create it with the ' + + 'group settings, then use Booking window settings to change the window for ' + + 'the whole group.', + ); + } + + // The rule this schedule is born under: staff overrides on top of the live + // global config, so an omitted field still follows the global value. + const windowCfg: BookingWindowConfig = dto.windowRule + ? { + ...globalCfg, + ...pickDefined({ + windowOpenHour: dto.windowRule.windowOpenHour, + windowCloseHour: dto.windowRule.windowCloseHour, + windowDurationHours: dto.windowRule.windowDurationHours, + docReviewMinutes: dto.windowRule.docReviewMinutes, + importWindowLeadDays: dto.windowRule.importWindowLeadDays, + exportBookingLeadHours: dto.windowRule.exportBookingLeadHours, + }), + // One pay-window override drives both directions (only the one + // matching this schedule's direction is ever read). + ...(dto.windowRule.paymentWindowMinutes !== undefined + ? { + paymentWindowMinutes: dto.windowRule.paymentWindowMinutes, + exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes, + } + : {}), + // Close offsets are nullable-by-intent: null/0 means "close at + // departure", which must override a non-null global, so these are + // merged on presence rather than on definedness. + ...(dto.windowRule.importCloseOffsetMinutes !== undefined + ? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null } + : {}), + ...(dto.windowRule.exportCloseOffsetMinutes !== undefined + ? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null } + : {}), + } + : globalCfg; + + // Staff cannot schedule inside the lead window — there must be room for a + // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT + // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT + // lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN + // lead, so a custom lead is honoured rather than rejected by the global one. + const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date()); + if (departure.getTime() < earliest.getTime()) { + const detail = + direction === 'EXPORT' + ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead` + : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`; + throw new BadRequestException( + `Departure ${departure.toISOString()} is inside the booking lead window; ` + + `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` + + `(earliest ${earliest.toISOString()})`, + ); + } + + // Freeze the rule this schedule is born with. A later global-rules edit + // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an + // already-open schedule keeps this snapshot, and the batch board draws its + // windows from it rather than the live config. + const ruleSnapshot = windowRuleSnapshot(windowCfg); const computedTimes = direction === 'EXPORT' ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } @@ -1514,12 +1577,30 @@ export class TrainSchedulingService { ...ruleSnapshot, ...computeImportWindowTimes(departure, windowCfg, new Date()), }; + if ( + computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime() + ) { + throw new BadRequestException( + 'These booking-window settings leave no window before departure — with the ' + + 'desk hours and close offset applied, the window would only open once the ' + + 'train has left.', + ); + } const windowFields = { bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', ...(groupAnchor ? this.groupWindowFieldsFrom(groupAnchor, departure) : computedTimes), + // `windowRuleSnapshot` never stamps the pay window (NULL = follow the + // live global value for the direction), so an explicit staff override is + // persisted here — the same field the post-creation override writes. + ...(dto.windowRule?.paymentWindowMinutes !== undefined + ? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes } + : {}), + // Hand-configured windows opt OUT of the global re-stamp, or the next + // global-rules edit would overwrite exactly what staff chose here. + windowRuleCustom: dto.windowRule != null, }; // A built train's own consist is the schedule's capacity: full when all // its wagons are allocated. Trains built without wagons yet fall back to diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx new file mode 100644 index 000000000..be913f5f0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx @@ -0,0 +1,82 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Paper, Stack, Text } from "@mantine/core"; + +export interface StackedBarSeries { + /** Key into each data row holding this series' value. */ + key: string; + label: string; + color: string; +} + +interface OverviewStackedBarChartProps { + title: string; + data: T[]; + /** Fixed order + fixed color per series — colors follow the entity, not the rank. */ + series: StackedBarSeries[]; + xKey?: string; + emptyMessage?: string; + formatXLabel?: (value: string) => string; +} + +export function OverviewStackedBarChart({ + title, + data, + series, + xKey = "date", + emptyMessage = "No data available", + formatXLabel, +}: OverviewStackedBarChartProps) { + const hasData = data.some((row) => + series.some((s) => Number((row as Record)[s.key]) > 0), + ); + + return ( + + + {title} + {!hasData ? ( + + {emptyMessage} + + ) : ( + + + + + + formatXLabel(String(v)))} /> + + {series.map((s, index) => ( + + ))} + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx index 13a357cce..7452e36cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -36,7 +36,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { const bookings = useOverviewBookingsTab(range, tab === "bookings"); const contracts = useOverviewContractsTab(range, tab === "contracts"); const billing = useOverviewBillingTab(range, tab === "billing"); - const operations = useOverviewOperationsTab(tab === "operations"); + const operations = useOverviewOperationsTab(range, tab === "operations"); const customers = useOverviewCustomersTab(range, tab === "customers"); const staff = useOverviewStaffTab(range, tab === "staff"); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index c294a5199..9dce6cc9e 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -71,7 +71,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps - + ({ @@ -81,10 +81,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps emptyMessage="No bookings yet" /> - - + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Bookings" + /> + + + ({ name: item.label, value: item.count, }))} @@ -92,14 +102,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps - ({ - label: item.label, - value: item.count, - }))} - /> - ); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx index 9786b11e2..e35600df9 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx @@ -94,7 +94,7 @@ export function OverviewContractsTabPanel({ - + ({ @@ -104,7 +104,7 @@ export function OverviewContractsTabPanel({ emptyMessage="No contracts yet" /> - + ({ @@ -113,16 +113,17 @@ export function OverviewContractsTabPanel({ }))} /> + + ({ + name: item.label === "CONTAINER" ? "Container" : "Bulk", + value: item.count, + }))} + /> + - ({ - label: item.label === "CONTAINER" ? "Container" : "Bulk", - value: item.count, - }))} - /> - ); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 355fb6b57..0fcdef9e6 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -1,13 +1,25 @@ -import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react"; +import { + Box, + CalendarClock, + Container as ContainerIcon, + Send, + Train, + Truck, +} from "lucide-react"; import { Grid, Stack } from "@mantine/core"; import type { IOverviewOperationsTab } from "@/types/overview"; import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart"; import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { OverviewStackedBarChart } from "../OverviewStackedBarChart"; -interface OverviewOperationsTabPanelProps { - data: IOverviewOperationsTab; -} +/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */ +const DIRECTION_SERIES = [ + { key: "exportCount", label: "Export", color: "#D98A0B" }, + { key: "importCount", label: "Import", color: "#0369a1" }, + { key: "domesticCount", label: "Domestic", color: "#7c3aed" }, +]; function formatStatusLabel(status: string) { return status @@ -16,6 +28,22 @@ function formatStatusLabel(status: string) { .replace(/\b\w/g, (char) => char.toUpperCase()); } +function formatDateLabel(date: string) { + const parsed = new Date(`${date}T00:00:00`); + return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function toDonutData(items: { status: string; count: number }[]) { + return items.map((item) => ({ + name: formatStatusLabel(item.status), + value: item.count, + })); +} + +interface OverviewOperationsTabPanelProps { + data: IOverviewOperationsTab; +} + export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) { return ( @@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP icon: Train, accent: "emerald", }, + { + label: "Upcoming departures", + value: data.kpis.schedulesUpcoming, + icon: CalendarClock, + accent: "sky", + hint: "Scheduled, not yet departed", + }, + { + label: "Dispatched today", + value: data.kpis.dispatchedToday, + icon: Send, + accent: "amber", + }, { label: "Wagons available", value: data.kpis.wagonsAvailable, @@ -45,41 +86,95 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP ]} /> + + + + + + + + + + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + /> + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + emptyMessage="No wagons assigned to yards" + /> + + + + + + ({ + label: item.label, + value: item.tons, + }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" + /> + + + ({ + name: item.label, + value: item.count, + }))} + /> + + + ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.trainStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.wagonStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.containerStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.cargoStatusBreakdown)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx new file mode 100644 index 000000000..b0de39baa --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx @@ -0,0 +1,366 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Badge, + Box, + Divider, + Group, + Loader, + NumberInput, + Select, + Stack, + Switch, + Text, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Info, Moon, Sun } from "lucide-react"; + +import DurationField from "@/components/trainScheduling/DurationField"; +import { trainSchedulingService } from "@/services/trainScheduling.service"; +import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling"; + +/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */ +const DEFAULTS = { + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + importWindowLeadDays: 3, + exportBookingLeadHours: 24, +}; + +/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ +function hourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const h12 = hour % 12 === 0 ? 12 : hour % 12; + return `${h12}:00 ${period}`; +} + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ + value: String(h), + label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`, +})); + +export interface WindowFormState { + windowOpenHour: number; + windowCloseHour: number; + windowDurationHours: number | ""; + docReviewMinutes: number | ""; + paymentWindowMinutes: number | ""; + importWindowLeadDays: number | ""; + exportBookingLeadHours: number | ""; + /** Blank = close exactly at departure. */ + closeOffsetMinutes: number | ""; +} + +/** + * Builds the create payload from form state, or returns an error message when a + * required field was left blank. The close offset is direction-scoped: only the + * offset matching this schedule's direction is sent, since the other is never read. + */ +export function buildWindowRulePayload( + form: WindowFormState, + isExport: boolean, +): { payload: CreateScheduleWindowRulePayload } | { error: string } { + const duration = Number(form.windowDurationHours); + const doc = Number(form.docReviewMinutes); + const pay = Number(form.paymentWindowMinutes); + const lead = Number(form.importWindowLeadDays); + const exportLead = Number(form.exportBookingLeadHours); + + const leadInvalid = isExport + ? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1 + : form.importWindowLeadDays === "" || !Number.isFinite(lead); + if ( + form.windowDurationHours === "" || + form.docReviewMinutes === "" || + form.paymentWindowMinutes === "" || + !Number.isFinite(duration) || + !Number.isFinite(doc) || + !Number.isFinite(pay) || + leadInvalid + ) { + return { error: "Fill every booking-window field, or turn the toggle off" }; + } + + // Blank offset = close at departure. Sent as null (not omitted) so it wins + // over a non-null global offset. + const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes); + + return { + payload: { + windowOpenHour: form.windowOpenHour, + windowCloseHour: form.windowCloseHour, + windowDurationHours: duration, + docReviewMinutes: doc, + paymentWindowMinutes: pay, + ...(isExport + ? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset } + : { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }), + }, + }; +} + +export interface CreateScheduleWindowFieldsProps { + /** Direction of the selected route — picks lead/offset semantics. */ + isExport: boolean; + form: WindowFormState | null; + onChange: (next: WindowFormState) => void; +} + +/** + * Booking-window settings for a schedule being created. Prefills from the live + * global rules (so the fields show what the schedule WOULD inherit), then lets + * staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus + * the booking-close offset. + */ +export default function CreateScheduleWindowFields({ + isExport, + form, + onChange, +}: CreateScheduleWindowFieldsProps) { + const rulesQuery = useQuery({ + queryKey: ["train-scheduling", "global-rules"], + queryFn: () => trainSchedulingService.getGlobalRules(), + staleTime: 5 * 60_000, + }); + + // Seed once from the global rules, so the toggle opens on the values this + // schedule would otherwise inherit rather than on hardcoded guesses. + const [seeded, setSeeded] = useState(false); + useEffect(() => { + if (seeded || form != null) return; + const r = rulesQuery.data; + if (!r && rulesQuery.isLoading) return; + const num = (v: unknown, fallback: number) => { + const n = v == null || v === "" ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + const offset = isExport + ? (r as { exportCloseOffsetMinutes?: number | null } | undefined) + ?.exportCloseOffsetMinutes + : (r as { importCloseOffsetMinutes?: number | null } | undefined) + ?.importCloseOffsetMinutes; + onChange({ + windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour), + windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour), + windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours), + docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes), + paymentWindowMinutes: num( + isExport + ? (r as { exportPaymentWindowMinutes?: number } | undefined) + ?.exportPaymentWindowMinutes + : r?.paymentWindowMinutes, + DEFAULTS.paymentWindowMinutes, + ), + importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays), + exportBookingLeadHours: num( + r?.exportBookingLeadHours, + DEFAULTS.exportBookingLeadHours, + ), + closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset), + }); + setSeeded(true); + }, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]); + + const set = (patch: Partial) => { + if (form) onChange({ ...form, ...patch }); + }; + + const is24h = form != null && form.windowOpenHour === form.windowCloseHour; + // Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning). + const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour; + + const reopenSummary = useMemo(() => { + if (!form) return ""; + const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0); + const h = Math.floor(total / 60); + const m = total % 60; + const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean); + return parts.length ? parts.join(" ") : "0m"; + }, [form]); + + if (!form) { + return ( + + + + ); + } + + return ( + + {isExport ? ( + }> + Export schedules use a single first-come-first-served window: it opens the + export lead time before departure — shifted to the next desk opening if that + lands outside desk hours — and stays open until it closes. Cycle timing below + doesn't apply. + + ) : ( + }> + These settings apply to this train only, and can be set only for the FIRST + train on a route and departure day. Later trains that day join its booking + group and share the same window. + + )} + + {/* ── Daily desk hours ─────────────────────────────────────────── */} + + + + Daily desk hours (EAT) + + {is24h ? ( + }> + 24-hour desk + + ) : ( + }> + {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} + + )} + + + v != null && set({ windowCloseHour: Number(v) })} + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + /> + + {isOvernight && !is24h ? ( + + Overnight desk — opens {form.windowOpenHour}:00 and runs past midnight, + closing {form.windowCloseHour}:00 the next morning. + + ) : null} + + set({ + // On → close == open (24h desk). Off → restore a normal ~9h day. + windowCloseHour: e.currentTarget.checked + ? form.windowOpenHour + : Math.min(23, form.windowOpenHour + 9), + }) + } + /> + + + + + {/* ── Cycle timing ─────────────────────────────────────────────── */} + + + Cycle timing + + + set({ windowDurationHours: v })} + min={0.0166} + disabled={isExport} + /> + + set({ docReviewMinutes: v })} + min={0} + disabled={isExport} + /> + set({ paymentWindowMinutes: v })} + min={1} + /> + + {!isExport ? ( + + Reopen gap after each cycle = document review + payment ={" "} + {reopenSummary}. + + ) : null} + + + + + + {/* ── Lead time ────────────────────────────────────────────────── */} + {isExport ? ( + set({ exportBookingLeadHours: v === "" ? "" : Number(v) })} + min={1} + clampBehavior="none" + allowNegative={false} + allowDecimal={false} + /> + ) : ( + set({ importWindowLeadDays: v === "" ? "" : Number(v) })} + min={0} + clampBehavior="none" + allowNegative={false} + allowDecimal={false} + /> + )} + + + + {/* ── Booking close offset ─────────────────────────────────────── */} + + + Booking close offset + + + How long before departure this schedule stops accepting bookings. e.g. a + 3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day + export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to + close exactly at departure. + + set({ closeOffsetMinutes: v })} + min={0} + /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 6e95a27fc..f6df662d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -184,7 +184,8 @@ export const QUERY_KEYS = { ["overview", "contracts", range ?? "30d"] as const, billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const, - operationsTab: () => ["overview", "operations"] as const, + operationsTab: (range?: string) => + ["overview", "operations", range ?? "30d"] as const, customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const, staffTab: (range?: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index f7f1f9cbc..9e343017e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -343,6 +343,10 @@ export const URL_CONSTANTS = { `/train-scheduling/bookings/${bookingId}/expire`, MOVE_BOOKING_SCHEDULE: (bookingId: string) => `/train-scheduling/bookings/${bookingId}/move-schedule`, + ALLOCATION_CANDIDATES: (bookingId: string) => + `/train-scheduling/bookings/${bookingId}/allocation-candidates`, + ALLOCATE_BOOKING: (bookingId: string) => + `/train-scheduling/bookings/${bookingId}/allocate`, GLOBAL_RULES: "/train-scheduling/global-rules", BOOKING_WINDOWS: "/train-scheduling/booking-windows", PREVIEW: "/train-scheduling/preview", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index 023d3d9f1..f46fe865f 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -35,10 +35,10 @@ export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) { }); } -export function useOverviewOperationsTab(enabled: boolean) { +export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean) { return useQuery({ - queryKey: QUERY_KEYS.OVERVIEW.operationsTab(), - queryFn: () => overviewService.getOperationsTab(), + queryKey: QUERY_KEYS.OVERVIEW.operationsTab(range), + queryFn: () => overviewService.getOperationsTab(range), enabled, }); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 0f8cc87ea..91eb52033 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -4,7 +4,9 @@ import { Box, Button, Card, + Checkbox, Group, + Modal, MultiSelect, Select, Stack, @@ -50,7 +52,10 @@ import { import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { api } from "@/services/api"; import type { BookingListFilter } from "@/services/bookings.service"; +import { trainSchedulingService } from "@/services/trainScheduling.service"; +import type { AllocationCandidate } from "@/types/trainScheduling"; import type { BookingListRow } from "@/types/booking"; +import { useToast } from "@/hooks/use-toast"; import { Badge, DataTable, @@ -155,6 +160,15 @@ export default function BookingRequestsPage() { const [scheduledTo, setScheduledTo] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); + // Paid bookings with no train attached (staff removed them or a sweep + // detached them) — the queue the per-row Allocate action works through. + const [paidUnallocated, setPaidUnallocated] = useState(false); + const [allocatingId, setAllocatingId] = useState(null); + const [otherDayModal, setOtherDayModal] = useState<{ + booking: BookingListRow; + candidates: AllocationCandidate[]; + } | null>(null); + const { toast } = useToast(); const suppressRowClickRef = useRef(false); const suppressRowClick = useCallback(() => { suppressRowClickRef.current = true; @@ -187,6 +201,10 @@ export default function BookingRequestsPage() { ...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), ...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}), + // Wins over the payment-status select — the queue is by definition PAID. + ...(paidUnallocated + ? { paymentStatus: "PAID", assignedToSchedule: "false" as const } + : {}), ...(ownershipFilter ? { isGovernment: ownershipFilter as "true" | "false" } : {}), @@ -208,6 +226,7 @@ export default function BookingRequestsPage() { directionFilter, freightTypeFilter, paymentStatusFilter, + paidUnallocated, ownershipFilter, originYardFilter, destinationYardFilter, @@ -251,6 +270,7 @@ export default function BookingRequestsPage() { (directionFilter ? 1 : 0) + (freightTypeFilter ? 1 : 0) + (paymentStatusFilter ? 1 : 0) + + (paidUnallocated ? 1 : 0) + (ownershipFilter ? 1 : 0) + (originYardFilter ? 1 : 0) + (destinationYardFilter ? 1 : 0) + @@ -263,6 +283,7 @@ export default function BookingRequestsPage() { setDirectionFilter(null); setFreightTypeFilter(null); setPaymentStatusFilter(null); + setPaidUnallocated(false); setOwnershipFilter(null); setOriginYardFilter(null); setDestinationYardFilter(null); @@ -301,6 +322,67 @@ export default function BookingRequestsPage() { [navigate], ); + // One click: same-day fit → allocate straight away. No same-day fit but a + // train on another date fits → let staff pick it (customer is notified of + // the date change by the API). Nothing fits → say so. + const handleAllocatePaid = useCallback( + async (row: BookingListRow) => { + setAllocatingId(row.id); + try { + const candidates = + await trainSchedulingService.getAllocationCandidates(row.id); + if (candidates.sameDay.length > 0) { + const target = candidates.sameDay[0]; + await trainSchedulingService.allocatePaidBooking(row.id, target.id); + toast({ + title: `Allocated ${row.reference}`, + description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`, + }); + void refetch(); + } else if (candidates.otherDays.length > 0) { + setOtherDayModal({ booking: row, candidates: candidates.otherDays }); + } else { + toast({ + title: "No fitting train", + description: + "No open schedule covers this booking's route with enough capacity.", + variant: "destructive", + }); + } + } catch { + toast({ title: "Allocation failed", variant: "destructive" }); + } finally { + setAllocatingId(null); + } + }, + [refetch, toast], + ); + + const handleAllocateOtherDay = useCallback( + async (candidate: AllocationCandidate) => { + if (!otherDayModal) return; + const { booking } = otherDayModal; + setAllocatingId(booking.id); + try { + await trainSchedulingService.allocatePaidBooking( + booking.id, + candidate.id, + ); + toast({ + title: `Allocated ${booking.reference}`, + description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`, + }); + setOtherDayModal(null); + void refetch(); + } catch { + toast({ title: "Allocation failed", variant: "destructive" }); + } finally { + setAllocatingId(null); + } + }, + [otherDayModal, refetch, toast], + ); + const columns: ColumnDef[] = [ { id: "booking", @@ -435,13 +517,33 @@ export default function BookingRequestsPage() { { id: "actions", size: 140, - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + const b = row.original; + const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId; + return ( + + {needsAllocation ? ( + + ) : null} + + + ); + }, }, ]; @@ -640,6 +742,16 @@ export default function BookingRequestsPage() { radius="lg" style={{ minWidth: 180 }} /> + { + setPaidUnallocated(e.currentTarget.checked); + resetPage(); + }} + radius="sm" + style={{ alignSelf: "center" }} + /> setParam("granularity", v)} + allowDeselect={false} + /> + ) : null} + {config.filters.includes("yards") ? ( + ({ + value: y.id, + label: y.label, + }))} + value={params.get("yardIds")?.split(",").filter(Boolean) ?? []} + onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)} + placeholder="All yards" + /> + ) : null} + {config.filters.includes("direction") ? ( + setParam("freightType", v)} + placeholder="All" + /> + ) : null} + {config.filters.includes("statuses") && config.statusOptions ? ( + setParam("statuses", v.length ? v.join(",") : null)} + placeholder="Default (active)" + /> + ) : null} + + + + + ({ + label: k.label, + value: k.value.toLocaleString(), + hint: k.unit, + }))} + /> + + + + void reportQuery.refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: rows.length, + }} + tableOptions={{ + manualPagination: false, + state: { pagination }, + onPaginationChange: setPagination, + autoResetPageIndex: false, + }} + footer={({ table, pagination: p }) => ( + + )} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx new file mode 100644 index 000000000..f8f6f161d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx @@ -0,0 +1,159 @@ +import { + ActionIcon, + Badge, + Card, + Group, + SimpleGrid, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { Search, Star } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { + REPORT_CONFIGS, + REPORT_DOMAINS, + type ReportConfig, +} from "./reportConfigs"; + +const FAVORITES_KEY = "reports.favorites"; + +const loadFavorites = (): string[] => { + try { + return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]"); + } catch { + return []; + } +}; + +function ReportCard({ + config, + favorite, + onToggleFavorite, +}: { + config: ReportConfig; + favorite: boolean; + onToggleFavorite: () => void; +}) { + const navigate = useNavigate(); + return ( + navigate(`/dashboard/reports/${config.key}`)} + > + +
+ + {config.title} + + + {config.description} + +
+ { + e.stopPropagation(); + onToggleFavorite(); + }} + > + + +
+ + {config.domain} + +
+ ); +} + +export default function ReportsHubPage() { + const [search, setSearch] = useState(""); + const [favorites, setFavorites] = useState(loadFavorites); + + const toggleFavorite = (key: string) => { + setFavorites((prev) => { + const next = prev.includes(key) + ? prev.filter((k) => k !== key) + : [...prev, key]; + localStorage.setItem(FAVORITES_KEY, JSON.stringify(next)); + return next; + }); + }; + + const visible = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return REPORT_CONFIGS; + return REPORT_CONFIGS.filter( + (c) => + c.title.toLowerCase().includes(q) || + c.description.toLowerCase().includes(q), + ); + }, [search]); + + const pinned = visible.filter((c) => favorites.includes(c.key)); + + const renderGrid = (configs: ReportConfig[]) => ( + + {configs.map((c) => ( + toggleFavorite(c.key)} + /> + ))} + + ); + + return ( + + } + placeholder="Search reports…" + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + } + /> + + {pinned.length ? ( + + Favorites + {renderGrid(pinned)} + + ) : null} + + {REPORT_DOMAINS.map((domain) => { + const configs = visible.filter((c) => c.domain === domain); + if (!configs.length) return null; + return ( + + {domain} + {renderGrid(configs)} + + ); + })} + + {visible.length === 0 ? ( + + No reports match “{search}” + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts new file mode 100644 index 000000000..3fac82442 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts @@ -0,0 +1,295 @@ +export type ReportDomain = "Commercial" | "Operations" | "Finance"; + +export type ReportColumnUnit = "ETB" | "t" | "%" | "min"; + +export interface ReportColumn { + key: string; + label: string; + /** Numeric unit — formats the cell (thousands separators, suffix). */ + unit?: ReportColumnUnit; + numeric?: boolean; +} + +export interface ReportChart { + type: "area" | "line" | "bar"; + xKey: string; + series: { key: string; label: string }[]; + /** Chart only the first N rows (rows arrive sorted by the backend). */ + topN?: number; +} + +export type ReportFilterKey = + | "granularity" + | "yards" + | "direction" + | "freightType" + | "statuses"; + +export interface ReportConfig { + key: string; + title: string; + description: string; + domain: ReportDomain; + filters: ReportFilterKey[]; + /** Options for the `statuses` filter, when enabled. */ + statusOptions?: string[]; + chart?: ReportChart; + columns: ReportColumn[]; +} + +const BOOKING_STATUSES = [ + "SUBMITTED", + "PENDING_APPROVAL", + "APPROVED", + "INVOICED", + "PAID", + "IN_TRANSIT", + "ARRIVED", + "COMPLETED", + "CANCELLED", + "REJECTED", +]; + +const CONTRACT_STATUSES = [ + "SUBMITTED", + "PENDING_APPROVAL", + "APPROVED", + "CONTRACT_ACTIVE", + "ACTIVE_SHIPMENT_IN_PROGRESS", + "SUSPENDED", + "CONTRACT_CLOSED", + "EXPIRED", + "CANCELLED", +]; + +const INVOICE_STATUSES = [ + "ISSUED", + "PENDING", + "PARTIALLY_PAID", + "PAID", + "OVERDUE", + "REFUNDED", +]; + +export const REPORT_CONFIGS: ReportConfig[] = [ + { + key: "bookings-trend", + title: "Bookings Trend", + description: "Booking volume, tonnage and revenue over time", + domain: "Commercial", + filters: ["granularity", "yards", "direction", "freightType", "statuses"], + statusOptions: BOOKING_STATUSES, + chart: { + type: "area", + xKey: "period", + series: [{ key: "revenue", label: "Revenue (ETB)" }], + }, + columns: [ + { key: "period", label: "Period" }, + { key: "bookings", label: "Bookings", numeric: true }, + { key: "tons", label: "Tonnage", unit: "t" }, + { key: "revenue", label: "Revenue", unit: "ETB" }, + ], + }, + { + key: "revenue-by-customer", + title: "Revenue by Customer", + description: "Ranked customers by booking revenue", + domain: "Commercial", + filters: ["yards", "direction", "freightType", "statuses"], + statusOptions: BOOKING_STATUSES, + chart: { + type: "bar", + xKey: "customer", + series: [{ key: "revenue", label: "Revenue (ETB)" }], + topN: 10, + }, + columns: [ + { key: "customer", label: "Customer" }, + { key: "bookings", label: "Bookings", numeric: true }, + { key: "tons", label: "Tonnage", unit: "t" }, + { key: "revenue", label: "Revenue", unit: "ETB" }, + ], + }, + { + key: "revenue-by-lane", + title: "Revenue by Lane", + description: "Origin → destination lanes by tonnage and revenue", + domain: "Commercial", + filters: ["direction", "freightType", "statuses"], + statusOptions: BOOKING_STATUSES, + chart: { + type: "bar", + xKey: "origin+destination", + series: [{ key: "revenue", label: "Revenue (ETB)" }], + topN: 10, + }, + columns: [ + { key: "origin", label: "Origin" }, + { key: "destination", label: "Destination" }, + { key: "bookings", label: "Bookings", numeric: true }, + { key: "tons", label: "Tonnage", unit: "t" }, + { key: "revenue", label: "Revenue", unit: "ETB" }, + ], + }, + { + key: "contract-utilization", + title: "Contract Utilization", + description: "Committed scope caps vs booked tonnage per contract", + domain: "Commercial", + filters: ["direction", "statuses"], + statusOptions: CONTRACT_STATUSES, + columns: [ + { key: "reference", label: "Contract" }, + { key: "customer", label: "Customer" }, + { key: "status", label: "Status" }, + { key: "kind", label: "Kind" }, + { key: "valid_from", label: "Valid from" }, + { key: "valid_until", label: "Valid until" }, + { key: "committed", label: "Committed", unit: "t" }, + { key: "booked_tons", label: "Booked", unit: "t" }, + { key: "bookings", label: "Bookings", numeric: true }, + { key: "utilization_pct", label: "Utilization", unit: "%" }, + ], + }, + { + key: "train-on-time", + title: "Train On-Time Performance", + description: "Departure punctuality and delays by lane (60-min grace)", + domain: "Operations", + filters: ["yards", "direction"], + chart: { + type: "bar", + xKey: "origin+destination", + series: [{ key: "on_time_pct", label: "On-time %" }], + topN: 15, + }, + columns: [ + { key: "origin", label: "Origin" }, + { key: "destination", label: "Destination" }, + { key: "trips", label: "Trips", numeric: true }, + { key: "departed", label: "Departed", numeric: true }, + { key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" }, + { key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" }, + { key: "on_time_pct", label: "On-time", unit: "%" }, + ], + }, + { + key: "schedule-fill-rate", + title: "Schedule Fill Rate", + description: "Booked tonnage vs wagon capacity per train schedule", + domain: "Operations", + filters: ["yards", "direction"], + chart: { + type: "line", + xKey: "departure", + series: [{ key: "fill_pct", label: "Fill %" }], + }, + columns: [ + { key: "train_number", label: "Train" }, + { key: "departure", label: "Departure" }, + { key: "origin", label: "Origin" }, + { key: "destination", label: "Destination" }, + { key: "direction", label: "Direction" }, + { key: "status", label: "Status" }, + { key: "wagon_count", label: "Wagons", numeric: true }, + { key: "capacity_tons", label: "Capacity", unit: "t" }, + { key: "booked_tons", label: "Booked", unit: "t" }, + { key: "fill_pct", label: "Fill", unit: "%" }, + ], + }, + { + key: "trips-per-route", + title: "Trips per Route", + description: "Completed trips and tonnage hauled per lane", + domain: "Operations", + filters: ["yards", "direction"], + chart: { + type: "bar", + xKey: "origin+destination", + series: [{ key: "trips", label: "Trips" }], + topN: 15, + }, + columns: [ + { key: "origin", label: "Origin" }, + { key: "destination", label: "Destination" }, + { key: "direction", label: "Direction" }, + { key: "trips", label: "Trips", numeric: true }, + { key: "tons_hauled", label: "Tonnage hauled", unit: "t" }, + { key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" }, + ], + }, + { + key: "invoiced-vs-collected", + title: "Invoiced vs Collected", + description: "Billing issued vs payments received over time", + domain: "Finance", + filters: ["granularity", "direction"], + chart: { + type: "line", + xKey: "period", + series: [ + { key: "invoiced", label: "Invoiced (ETB)" }, + { key: "collected", label: "Collected (ETB)" }, + ], + }, + columns: [ + { key: "period", label: "Period" }, + { key: "invoices", label: "Invoices", numeric: true }, + { key: "invoiced", label: "Invoiced", unit: "ETB" }, + { key: "collected", label: "Collected", unit: "ETB" }, + { key: "outstanding", label: "Outstanding", unit: "ETB" }, + ], + }, + { + key: "aging-receivables", + title: "Aging Receivables", + description: "Outstanding invoice balances by age bucket per customer", + domain: "Finance", + filters: ["direction", "statuses"], + statusOptions: INVOICE_STATUSES, + chart: { + type: "bar", + xKey: "customer", + series: [{ key: "outstanding", label: "Outstanding (ETB)" }], + topN: 10, + }, + columns: [ + { key: "customer", label: "Customer" }, + { key: "invoices", label: "Invoices", numeric: true }, + { key: "outstanding", label: "Outstanding", unit: "ETB" }, + { key: "current", label: "Current", unit: "ETB" }, + { key: "overdue_0_30", label: "0–30d", unit: "ETB" }, + { key: "overdue_31_60", label: "31–60d", unit: "ETB" }, + { key: "overdue_61_90", label: "61–90d", unit: "ETB" }, + { key: "overdue_90_plus", label: "90d+", unit: "ETB" }, + ], + }, + { + key: "revenue-by-payment-method", + title: "Revenue by Payment Method", + description: "Successful payments broken down by method", + domain: "Finance", + filters: ["direction"], + chart: { + type: "bar", + xKey: "method", + series: [{ key: "amount", label: "Amount (ETB)" }], + }, + columns: [ + { key: "method", label: "Method" }, + { key: "payments", label: "Payments", numeric: true }, + { key: "amount", label: "Amount", unit: "ETB" }, + ], + }, +]; + +export const REPORT_CONFIG_BY_KEY = new Map( + REPORT_CONFIGS.map((c) => [c.key, c]), +); + +export const REPORT_DOMAINS: ReportDomain[] = [ + "Commercial", + "Operations", + "Finance", +]; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 9933fbccf..b21ec07a0 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -163,6 +163,8 @@ import { type SaveLocomotivePayload, } from "./locomotives.service"; import { overviewService } from "./overview.service"; +import { reportsService } from "./reports.service"; +import type { ReportQueryInput, ReportResult } from "@/types/reports"; import { paymentsService, type PaginatedPayments, @@ -2880,4 +2882,13 @@ export const api = { ({ range }) => overviewService.getDashboard(range), ), }, + + reports: { + run: endpoint( + "reports", + "run", + (input) => reportsService.run(input), + (input) => ["reports", input.key, input], + ), + }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/reports.service.ts b/apps/edr-freight-web/backoffice/src/services/reports.service.ts new file mode 100644 index 000000000..6f6995614 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/reports.service.ts @@ -0,0 +1,14 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { ReportQueryInput, ReportResult } from "@/types/reports"; + +export const reportsService = { + run: async ({ key, ...params }: ReportQueryInput): Promise => { + const response = await client.get( + URL_CONSTANTS.REPORTS.RUN(key), + { params }, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/reports.ts b/apps/edr-freight-web/backoffice/src/types/reports.ts new file mode 100644 index 000000000..72326788c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/reports.ts @@ -0,0 +1,27 @@ +export interface ReportKpi { + label: string; + value: number; + unit?: string; +} + +export type ReportRow = Record; + +export interface ReportResult { + kpis: ReportKpi[]; + rows: ReportRow[]; +} + +/** Query params for GET /reports/:key. List filters are comma-separated. */ +export interface ReportQueryInput { + key: string; + dateFrom?: string; + dateTo?: string; + granularity?: "day" | "week" | "month"; + companyIds?: string; + routeIds?: string; + yardIds?: string; + cargoTypeIds?: string; + statuses?: string; + direction?: string; + freightType?: string; +} diff --git a/packages/types/src/freight/overview.ts b/packages/types/src/freight/overview.ts index 91675b1fc..e3d98bc00 100644 --- a/packages/types/src/freight/overview.ts +++ b/packages/types/src/freight/overview.ts @@ -1,6 +1,8 @@ export type OverviewRange = '7d' | '30d' | '90d'; export interface IOverviewBookingKpis { + /** All bookings ever recorded (excluding deleted / GENERAL umbrella rows). */ + total: number; totalActive: number; needsAction: number; urgent: number; @@ -35,6 +37,8 @@ export interface IOverviewStaffKpis { } export interface IOverviewContractKpis { + /** All contracts ever recorded (excluding deleted). */ + total: number; totalActive: number; needsAction: number; inApproval: number; @@ -202,5 +206,6 @@ export type OverviewTabKey = | 'contracts' | 'billing' | 'operations' + | 'fleet' | 'customers' | 'staff'; From 5e8eec95393731c2b3614188c431c132bad4d0b1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 21:58:35 +0000 Subject: [PATCH 09/10] add reports module with controller, service, and repository --- .../src/modules/reports/report-queries.ts | 271 ++++++++++++++++-- .../src/modules/reports/reports.service.ts | 9 +- .../src/pages/reports/ReportPage.tsx | 4 +- .../src/pages/reports/reportConfigs.ts | 141 ++++++++- 4 files changed, 392 insertions(+), 33 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts index e329c8df6..9e4a6f617 100644 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ b/apps/edr-freight-api/src/modules/reports/report-queries.ts @@ -1,10 +1,10 @@ import { DataSource } from 'typeorm'; export interface ReportFilters { - /** ISO timestamp, inclusive lower bound. */ - dateFrom: string; - /** ISO timestamp, exclusive upper bound. */ - dateTo: string; + /** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */ + dateFrom: string | null; + /** ISO timestamp, exclusive upper bound. null = no upper bound. */ + dateTo: string | null; granularity: 'day' | 'week' | 'month'; companyIds: string[] | null; routeIds: string[] | null; @@ -52,7 +52,8 @@ function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } { where: ` b.deleted_at IS NULL AND ${NOT_UMBRELLA} - AND b.created_at >= $1::timestamptz AND b.created_at < $2::timestamptz + AND ($1::timestamptz IS NULL OR b.created_at >= $1) + AND ($2::timestamptz IS NULL OR b.created_at < $2) AND ($3::uuid[] IS NULL OR b.company_id = ANY($3)) AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4)) AND ($5::text[] IS NULL OR b.trade_direction = ANY($5)) @@ -182,8 +183,9 @@ const contractUtilization: ReportQuery = async (ds, f) => { AND b.status NOT IN (${DEAD_STATUSES})) booked ON true WHERE ct.deleted_at IS NULL AND ct.status NOT IN ('DRAFT') - AND ct.contract_valid_from < $2::timestamptz - AND (ct.contract_valid_until IS NULL OR ct.contract_valid_until >= $1::timestamptz) + AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity') + AND (ct.contract_valid_until IS NULL + OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity')) AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) AND ($5::text[] IS NULL OR ct.status = ANY($5)) @@ -227,8 +229,8 @@ const trainOnTime: ReportQuery = async (ds, f) => { JOIN freight.yards d ON d.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) AND ($4::text[] IS NULL OR ts.direction = ANY($4)) AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) @@ -280,8 +282,8 @@ const scheduleFillRate: ReportQuery = async (ds, f) => { WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true WHERE ts.deleted_at IS NULL AND ts.status <> 'CANCELLED' - AND ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) AND ($4::text[] IS NULL OR ts.direction = ANY($4)) AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) @@ -319,8 +321,8 @@ const tripsPerRoute: ReportQuery = async (ds, f) => { WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true WHERE ts.deleted_at IS NULL AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3)) AND ($4::text[] IS NULL OR ts.direction = ANY($4)) AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) @@ -347,8 +349,8 @@ const invoicedVsCollected: ReportQuery = async (ds, f) => { FROM freight.invoices i WHERE i.deleted_at IS NULL AND i.status NOT IN ('DRAFT', 'CANCELLED') - AND COALESCE(i.issued_at, i.created_at) >= $1::timestamptz - AND COALESCE(i.issued_at, i.created_at) < $2::timestamptz + AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1) + AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2) AND ($3::uuid[] IS NULL OR i.company_id = ANY($3)) AND ${refDirScope('i.source_id', '$4')} GROUP BY 1 ORDER BY 1`, @@ -371,26 +373,27 @@ const invoicedVsCollected: ReportQuery = async (ds, f) => { }; }; -// Aging is an as-of snapshot: dateTo is the as-of moment, dateFrom is ignored. +// Aging is an as-of snapshot: dateTo is the as-of moment (default now), +// dateFrom is ignored. const agingReceivables: ReportQuery = async (ds, f) => { const rows = await ds.query( `SELECT c.name AS customer, COUNT(*)::int AS invoices, ROUND(SUM(i.balance_amount))::float8 AS outstanding, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= $1::timestamptz), 0))::float8 AS current, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - AND i.due_at >= $1::timestamptz - interval '30 days'), 0))::float8 AS overdue_0_30, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '30 days' - AND i.due_at >= $1::timestamptz - interval '60 days'), 0))::float8 AS overdue_31_60, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '60 days' - AND i.due_at >= $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_61_90, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_90_plus + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) + AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days' + AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days' + AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90, + ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus FROM freight.invoices i JOIN freight.companies c ON c.id = i.company_id WHERE i.deleted_at IS NULL AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE') AND i.balance_amount > 0 - AND i.created_at < $1::timestamptz + AND ($1::timestamptz IS NULL OR i.created_at < $1) AND ($2::uuid[] IS NULL OR i.company_id = ANY($2)) AND ${refDirScope('i.source_id', '$3')} GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`, @@ -416,7 +419,8 @@ const revenueByPaymentMethod: ReportQuery = async (ds, f) => { ROUND(SUM(p.amount))::float8 AS amount FROM freight.payments p WHERE p.status = 'success' - AND p.created_at >= $1::timestamptz AND p.created_at < $2::timestamptz + AND ($1::timestamptz IS NULL OR p.created_at >= $1) + AND ($2::timestamptz IS NULL OR p.created_at < $2) AND ${refDirScope('p.ref_id', '$3')} GROUP BY 1 ORDER BY amount DESC`, [f.dateFrom, f.dateTo, f.directions], @@ -436,7 +440,222 @@ const revenueByPaymentMethod: ReportQuery = async (ds, f) => { }; }; +// --------------------------------------------------------------------------- +// Record-level list exports. Same engine, raw rows instead of aggregates. +// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table +// ever outgrows that. +const LIST_LIMIT = 5000; + +const bookingsList: ReportQuery = async (ds, f) => { + const { where, params } = bookingWhere(f); + const rows = await ds.query( + `SELECT b.reference, + to_char(b.created_at, 'YYYY-MM-DD') AS created, + c.name AS customer, b.status, b.freight_type, + b.trade_direction AS direction, + o.label AS origin, d.label AS destination, + COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo, + ROUND(${TONS})::float8 AS tons, + ROUND(${REVENUE})::float8 AS amount, + b.payment_status, b.scheduling_status + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + JOIN freight.yards o ON o.id = b.origin_yard_id + JOIN freight.yards d ON d.id = b.destination_yard_id + LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id + WHERE ${where} + ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`, + params, + ); + return { + kpis: [ + { label: 'Bookings', value: rows.length }, + { label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' }, + { label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' }, + ], + rows, + }; +}; + +const contractsList: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind, + ct.status, ct.trade_direction AS direction, ct.freight_type, + to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from, + to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until, + to_char(ct.created_at, 'YYYY-MM-DD') AS created + FROM freight.contracts ct + LEFT JOIN freight.companies c ON c.id = ct.company_id + WHERE ct.deleted_at IS NULL + AND ($1::timestamptz IS NULL OR ct.created_at >= $1) + AND ($2::timestamptz IS NULL OR ct.created_at < $2) + AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3)) + AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4)) + AND ($5::text[] IS NULL OR ct.status = ANY($5)) + ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses], + ); + const active = rows.filter((r: Record) => + ['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)), + ).length; + return { + kpis: [ + { label: 'Contracts', value: rows.length }, + { label: 'Active', value: active }, + ], + rows, + }; +}; + +const schedulesList: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT ts.train_number, ts.reference, ts.direction, ts.status, + o.label AS origin, d.label AS destination, + to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure, + to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure, + to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival, + to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival, + ts.max_wagons, tset.wagon_count + FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id + JOIN freight.yards d ON d.id = ts.destination_station_id + LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE ts.deleted_at IS NULL + AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1) + AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2) + AND ($3::text[] IS NULL OR ts.direction = ANY($3)) + AND ($4::text[] IS NULL OR ts.status = ANY($4)) + AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5)) + ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds], + ); + const count = (s: string) => + rows.filter((r: Record) => r.status === s).length; + return { + kpis: [ + { label: 'Schedules', value: rows.length }, + { label: 'Dispatched', value: count('DISPATCHED') }, + { label: 'Arrived', value: count('ARRIVED') }, + ], + rows, + }; +}; + +const fleetWagons: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT w.wagon_number, wt.name AS type, + wt.capacity_tons::float8 AS capacity_tons, + w.status, y.label AS current_yard + FROM freight.wagons w + JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id + LEFT JOIN freight.yards y ON y.id = w.current_yard_id + WHERE w.deleted_at IS NULL + AND ($1::text[] IS NULL OR w.status = ANY($1)) + AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2)) + ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`, + [f.statuses, f.yardIds], + ); + const count = (s: string) => + rows.filter((r: Record) => r.status === s).length; + return { + kpis: [ + { label: 'Wagons', value: rows.length }, + { label: 'Available', value: count('AVAILABLE') }, + { label: 'Assigned', value: count('ASSIGNED') }, + { label: 'Maintenance', value: count('MAINTENANCE') }, + ], + rows, + }; +}; + +const fleetLocomotives: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT l.code, l.name, l.locomotive_type, + l.max_pull_weight_tons::float8 AS max_pull_tons, + l.status, y.label AS current_yard + FROM freight.locomotives l + LEFT JOIN freight.yards y ON y.id = l.current_yard_id + WHERE l.deleted_at IS NULL + AND ($1::text[] IS NULL OR l.status = ANY($1)) + AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2)) + ORDER BY l.code LIMIT ${LIST_LIMIT}`, + [f.statuses, f.yardIds], + ); + const available = rows.filter( + (r: Record) => r.status === 'AVAILABLE', + ).length; + return { + kpis: [ + { label: 'Locomotives', value: rows.length }, + { label: 'Available', value: available }, + ], + rows, + }; +}; + +const customersList: ReportQuery = async (ds, f) => { + const rows = await ds.query( + `SELECT c.name, c.type, c.kind, c.status, c.tin, + to_char(c.approved_at, 'YYYY-MM-DD') AS approved, + to_char(c.created_at, 'YYYY-MM-DD') AS created + FROM freight.companies c + WHERE c.deleted_at IS NULL + AND ($1::timestamptz IS NULL OR c.created_at >= $1) + AND ($2::timestamptz IS NULL OR c.created_at < $2) + AND ($3::text[] IS NULL OR c.status = ANY($3)) + ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.statuses], + ); + const active = rows.filter( + (r: Record) => r.status === 'active', + ).length; + return { + kpis: [ + { label: 'Customers', value: rows.length }, + { label: 'Active', value: active }, + ], + rows, + }; +}; + +const paymentsList: ReportQuery = async (ds, f) => { + // No deleted_at on freight.payments; statuses are lowercase-hyphenated. + const rows = await ds.query( + `SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created, + p.method::text AS method, p.status::text AS status, + p.currency::text AS currency, + ROUND(p.amount)::float8 AS amount, + p.transaction_id, p.merchant_order_id, + to_char(p.paid_at, 'YYYY-MM-DD') AS paid + FROM freight.payments p + WHERE ($1::timestamptz IS NULL OR p.created_at >= $1) + AND ($2::timestamptz IS NULL OR p.created_at < $2) + AND ($3::text[] IS NULL OR p.status::text = ANY($3)) + AND ${refDirScope('p.ref_id', '$4')} + ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`, + [f.dateFrom, f.dateTo, f.statuses, f.directions], + ); + const success = rows.filter( + (r: Record) => r.status === 'success', + ); + return { + kpis: [ + { label: 'Payments', value: rows.length }, + { label: 'Successful', value: success.length }, + { label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' }, + ], + rows, + }; +}; + export const REPORT_QUERIES: Record = { + 'bookings-list': bookingsList, + 'contracts-list': contractsList, + 'schedules-list': schedulesList, + 'fleet-wagons': fleetWagons, + 'fleet-locomotives': fleetLocomotives, + 'customers-list': customersList, + 'payments-list': paymentsList, 'bookings-trend': bookingsTrend, 'revenue-by-customer': revenueByCustomer, 'revenue-by-lane': revenueByLane, diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts index 0e5c7d928..04e6e9a60 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.service.ts @@ -25,12 +25,13 @@ export class ReportsService { if (!(key in REPORT_QUERIES)) { throw new NotFoundException(`Unknown report: ${key}`); } - const to = dto.dateTo ? new Date(dto.dateTo) : new Date(); - const from = dto.dateFrom ? new Date(dto.dateFrom) : new Date(to.getTime() - 30 * DAY_MS); + // No default range: absent dates mean all time, so exports cover everything. + const to = dto.dateTo ? new Date(dto.dateTo) : null; + const from = dto.dateFrom ? new Date(dto.dateFrom) : null; const filters: ReportFilters = { - dateFrom: from.toISOString(), + dateFrom: from ? from.toISOString() : null, // dateTo is inclusive in the API; queries treat the bound as exclusive. - dateTo: new Date(to.getTime() + DAY_MS).toISOString(), + dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null, granularity: dto.granularity ?? 'day', companyIds: list(dto.companyIds), routeIds: list(dto.routeIds), diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx index 0ac68325d..939a6423a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx @@ -305,7 +305,7 @@ export default function ReportPage() { value={toDate(params.get("dateFrom"))} maxDate={toDate(params.get("dateTo")) ?? undefined} onChange={(d) => setParam("dateFrom", toParam(d))} - placeholder="30 days ago" + placeholder="All time" /> setParam("dateTo", toParam(d))} - placeholder="Today" + placeholder="All time" /> {config.filters.includes("granularity") ? (