mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix issue
This commit is contained in:
457
apps/edr-freight-api/FREIGHT_QA_REPORT.md
Normal file
457
apps/edr-freight-api/FREIGHT_QA_REPORT.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# EDR Freight — Full QA / Logic Audit Report
|
||||
|
||||
**Date:** 2026-07-15
|
||||
**Scope:** `@edr/freight-api` + freight backoffice/portal web apps
|
||||
**Excluded per request:** warehouses, first-mile, last-mile, onboarding (Fayda/verifayda)
|
||||
**Environment:** live stack via `pnpm run dev:freight`, API on `http://localhost:3030`, DB `edr_freight` @ `10.18.7.207`
|
||||
**Method:** booted the real app, logged in as `superadmin@tria.com`, drove the API with `curl`, reproduced state bugs against live data, read every service/controller/entity in the in-scope modules, and cross-checked the backoffice screens against the API.
|
||||
|
||||
---
|
||||
|
||||
## 1. How to read this report
|
||||
|
||||
Every finding has: **Problem** (what is wrong + concrete failure), **Impact**, **Fix** (code-level, with `file:line`), and — where I ran it live — a **Repro** block with the actual request/response.
|
||||
|
||||
Severity:
|
||||
|
||||
- **CRITICAL** — money moves wrongly, or anyone can act on anyone's data / settle invoices.
|
||||
- **HIGH** — data corruption, cross-tenant read/write, physical-asset state diverges from reality.
|
||||
- **MEDIUM** — wrong-but-recoverable state, missing guards, math/notification errors.
|
||||
- **LOW** — hardening, stale displays, latent (unused endpoint) bugs.
|
||||
|
||||
**Counts:** 6 Critical · 18 High · 27 Medium · 20 Low (≈71 distinct issues).
|
||||
|
||||
The single most valuable structural fix is at the top of §4 — it eliminates a whole family of bugs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Live tests I actually ran (evidence)
|
||||
|
||||
| # | Test | Result | Verdict |
|
||||
|---|------|--------|---------|
|
||||
| 1 | Login `superadmin@tria.com` | `success:true` + JWT | OK |
|
||||
| 2 | Build train `TR-00002` in KALITY, 2 locomotives | created | OK |
|
||||
| 3 | **Move coupled LOCO-004 to MOJO while train stays in KALITY** | `200`, train KALITY / loco MOJO | **BUG — your example, confirmed** |
|
||||
| 4 | Re-use same locomotive on a 2nd train | `409 already coupled` | Guard OK |
|
||||
| 5 | `PATCH /train-builder/:id/yard` → MOJO | coupled locos + wagons follow | OK (builder path correct) |
|
||||
| 6 | Decommission a coupled locomotive | `200 OUT_OF_SERVICE` | **BUG — no coupling guard** |
|
||||
| 7 | Attach KALITY wagon to MOJO train | `400 not in yard` | Guard OK |
|
||||
| 8 | `PATCH /wagons/:id` yard → different yard while ASSIGNED | `200` accepted | **BUG** |
|
||||
| 9 | `DELETE /wagons/:id` on a wagon coupled to a train | `200` + **row physically gone** | **BUG — hard delete, no guard** |
|
||||
| 10 | `GET /payments/checkout` **no auth** | `200` | **BUG — public** |
|
||||
| 11 | `POST /internal/payments/mark-paid` **no auth** | `400` (reached handler, not `401`) | **BUG — public** |
|
||||
| 12 | `GET /payments/receipt/:id` **no auth** | `400` (reached handler) | **BUG — public** |
|
||||
| 13 | `GET /files/:id` **no auth** | `404` (reached handler) | **BUG — public** |
|
||||
| 14 | Control: `GET /locomotives`, `GET /incidents` no auth | `401` | Auth global guard works |
|
||||
|
||||
Tests 10–14 prove the "public" endpoints are genuinely reachable without a token (protected routes return `401`; these return `400`/`404`/`200` because they hit the handler).
|
||||
|
||||
> **Boot-time noise (not app bugs):** SMS + Email services fail on RabbitMQ `ACCESS_REFUSED` (broker creds), and Swagger warns about 3 duplicate DTO names (`UpdateProfileDto`, `RequestChangesDto`, `SignContractDto`) and a legacy `/api/*` route. See §9.
|
||||
|
||||
---
|
||||
|
||||
## 3. CRITICAL — money & authorization
|
||||
|
||||
### C1. Paying an invoice settles it instantly without any money moving
|
||||
**File:** `edr-platform/apps/edr-freight-api/src/modules/billing/billing.service.ts:973-986`
|
||||
A shipped "DEMO" shortcut fakes a `payment.succeeded` callback the moment a payment is initiated:
|
||||
```ts
|
||||
// DEMO: manually fire the gateway 'payment.succeeded' callback here …
|
||||
if (!result.immediateSuccess) { await this.payment.handlePaymentEvent({ eventType: "payment.succeeded", … }) }
|
||||
```
|
||||
Real gateways return `REQUIRES_ACTION` (redirect), so `immediateSuccess` is false for essentially every payment → the invoice is marked **PAID**, the booking advances to `PAID`/batch allocation, and the clearance-fee gate opens. **A customer clicks "Pay", closes the page, pays nothing, and the freight ships.** If they *do* pay, the later real webhook is a no-op and money is collected against an already-settled invoice with no reconciliation.
|
||||
**Fix:** delete the `if (!result.immediateSuccess)` block. Settle only from `settleByPaymentId` on a verified provider signal, and check `intent.amountMinor` against the invoice balance at settle time.
|
||||
|
||||
### C2. Unauthenticated endpoints can settle any invoice by ID *(live-confirmed)*
|
||||
**Files:** `billing/payment.controller.ts:55-104` (`GET /payments/checkout`, `@Public()`), `payment/internal-payment.controller.ts:22-39` (`POST /internal/payments/mark-paid`, `@Public()` — its own comment: *"anyone who can reach the API can mark payments as paid"*), `billing/payment.controller.ts:39-53` (`POST /payments/initiate`, no guard, no ownership check).
|
||||
**Repro (live):**
|
||||
```
|
||||
GET /api/payments/checkout?invoiceId=…&method=TELEBIRR → 200 (no token)
|
||||
POST /api/internal/payments/mark-paid → 400 (no token, reached handler)
|
||||
GET /api/locomotives → 401 (control)
|
||||
```
|
||||
Combined with **C1**, anyone with an invoice UUID marks it paid unauthenticated and ships the freight. Anyone can POST a forged `payment.succeeded` (only a booking id is needed) to `mark-paid`.
|
||||
**Fix:** shared-secret / `x-service-token` guard on `mark-paid` (the payment API already sends one — `http-payment-event-publisher.ts:44-45`); require auth + ownership on `initiate`; make `checkout` a signed, expiring URL.
|
||||
|
||||
### C3. Currency-unit chaos — providers disagree by 100× on the same charge
|
||||
**Files:** `billing.service.ts:958` sends `amountMinor: Math.round(Number(invoice.balanceAmount))` (invoice amounts are **major** units, `numeric(14,2)`). Providers then split: `cbe-birr.provider.ts:57`, `ebirr.provider.ts:64`, `card.provider.ts:60`, `cac-bank.provider.ts:307` all do `amountMinor / 100`; but `telebirr.provider.ts:201`, `dmoney.provider.ts:200`, `waafi.provider.ts:282` treat it as major (no `/100`).
|
||||
**Impact:** a 50,000 ETB invoice paid via CBE_BIRR/EBIRR/CARD/CAC charges **500.00** — while the freight side still marks it fully PAID (see C4). `Math.round` also drops cents.
|
||||
**Fix:** pick one convention (`Math.round(balance*100)` true-minor everywhere, fix the 3 major-unit providers), then enforce `confirmedAmountMinor === intent.amountMinor` at settlement.
|
||||
|
||||
### C4. Settlement never verifies the amount charged
|
||||
**Files:** `billing.service.ts:527-552` (`markInvoiceAsPaid` sets `paidAmount = totalAmount, balanceAmount = 0` purely from the invoice); `edr-payment-api/.../intents/intents.service.ts:408-415` (an amount mismatch is only `logger.error`'d — the intent still finalizes SUCCEEDED, and no freight handler even populates `confirmedAmountMinor`).
|
||||
**Impact:** any wrong-amount success (C3's 1/100, a partial card capture, a reused stale intent) still marks the full invoice PAID.
|
||||
**Fix:** treat a `confirmedAmountMinor` mismatch as a failure/hold; in `settleByPaymentId` refuse (or record a partial) when the confirmed amount doesn't cover `balanceAmount`.
|
||||
|
||||
### C5. Telebirr & D-Money webhook signatures are disabled
|
||||
**Files:** `edr-payment-api/.../webhooks/handlers/telebirr-webhook.service.ts:16-18` (`const signatureValid = true;` + `// TODO: re-enable`), `dmoney-webhook.service.ts:16-17` (same). The pipeline only rejects when `signatureValid` is false, so these two are trusted unconditionally on the public `/webhooks/*` surface.
|
||||
**Impact:** an attacker who guesses a `merch_order_id` POSTs a fake success payload → intent SUCCEEDED → freight marks the invoice PAID and ships.
|
||||
**Fix:** implement `verifyWebhookSignature` for both; until keys exist, re-query the provider (`queryStatus`) before honoring SUCCEEDED.
|
||||
|
||||
### C6. Customers can create/act-on bookings billed to any company
|
||||
**Files:** `bookings/bookings.service.ts:594-629` (`create` only resolves+verifies the caller's own company when `dto.companyId` is *absent*; a supplied `companyId` is used verbatim), plus a batch of booking mutations with **no ownership check** in `bookings.controller.ts`: `PATCH /:id` (205), `DELETE /:id` (585), `POST /:id/generate-price` (611), `/submit` (622), `/confirm-submit` (633), `/reject` (643), `/clearance/documents` (682), `/clearance/proceed` (699), `GET /:id/clearance` (672). Reads (`GET /:id`) *do* call `assertCustomerCanAccessBooking`; writes don't.
|
||||
**Impact:** Company A's user sets `companyId` to Company B and books/invoices under B; or calls `DELETE /bookings/{B's id}` / submits / uploads clearance docs on B's booking. UUIDs appear in list payloads, so they're discoverable within a session.
|
||||
**Fix:** require a staff permission to pass `companyId`; otherwise force it from the resolved user company. Add `assertCustomerCanAccessBooking` to every mutating booking route.
|
||||
|
||||
---
|
||||
|
||||
## 4. HIGH — Train / Locomotive / Wagon consist integrity
|
||||
|
||||
> **★ Structural root cause (fix this first).** The invariant *"a consist (train + its locomotives + its wagons) moves and locks as one unit"* is enforced **only inside** `trains/train-builder.service.ts`. Every *legacy / master-data* endpoint around it — `PATCH /locomotives/:id`, `PATCH /wagons/:id`, `POST /wagons/:id/assign-train`, `wagons/bulk-status`, `wagons/bulk-transfer`, `DELETE /trains/:id`, `DELETE /wagons/:id` — mutates the same rows with **no coupling guard**. Findings H1, H2, H5, H6, H7 are all the same missing check. **The one fix that kills the family:** make `TrainLocomotive`/`wagon.trainId` membership a guard that every locomotive/wagon mutation consults (reject or redirect to the builder endpoints).
|
||||
|
||||
### H1. ★ Locomotive yard/status freely editable while coupled to a built train — *your example, confirmed live*
|
||||
**File:** `locomotives/locomotives.service.ts:87-119` (`update`).
|
||||
`update()` applies `status` and `currentYardId` with **no check of the `train_locomotives` link table**:
|
||||
```ts
|
||||
currentYardId: dto.currentYardId === undefined ? locomotive.currentYardId : (dto.currentYardId ?? null),
|
||||
```
|
||||
The builder keeps train + locomotives in the same yard (`train-builder.service.ts:389-429 setYard`, `:656-660 validateAndLockLocomotives`), but `PATCH /locomotives/:id` bypasses it. **The frontend triggers it by accident:** the locomotive edit form always includes the `status` and `currentYardId` selects (`.../pages/fleet/config/resources.ts:168-169`) and PATCHes the *whole form object* on every save (`FleetResourcePage.tsx:301`). So editing a coupled locomotive's *name* re-sends its yard → the train and its locomotive end up in different yards.
|
||||
**Repro (live):**
|
||||
```
|
||||
Build TR-00002 in KALITY with LOCO-004 (+LOCO-023)
|
||||
PATCH /api/locomotives/LOCO-004 {"currentYardId": MOJO} → 200
|
||||
GET train → yard: KALITY ; LOCO-004 → yard: MOJO ← diverged, no error
|
||||
```
|
||||
**Fix (exactly what you described — reject with a clear error, don't silently move):**
|
||||
```ts
|
||||
const link = await this.ds.getRepository(TrainLocomotive)
|
||||
.findOne({ where: { locomotiveId: id }, relations: { train: true } });
|
||||
if (link) {
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId)
|
||||
throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; move the train instead`);
|
||||
if (dto.status !== undefined && dto.status !== loco.status)
|
||||
throw new ConflictException(`Locomotive ${loco.code} is coupled to train ${link.train?.code}; detach it before changing status`);
|
||||
}
|
||||
```
|
||||
Frontend: send only dirty fields, and disable the yard/status selects (show the modal error) when the locomotive is in a built train.
|
||||
|
||||
### H2. Wagon PATCH is a free-for-all — yard, `trainId`, `sequenceNumber`, `status` all unguarded *(live-confirmed)*
|
||||
**File:** `wagons/wagons.service.ts:90-101` — `Object.assign(wagon, dto)` with zero invariant checks; `UpdateWagonDto` (PartialType of Create) exposes `trainId`, `sequenceNumber`, `status`, `currentYardId`.
|
||||
**Repro (live):** `PATCH /api/wagons/:id {"currentYardId": MOJO}` on a wagon `ASSIGNED` to a KALITY train returned `200` and moved it. Setting `trainId` directly attaches to any train bypassing every builder rule; flipping `ASSIGNED→AVAILABLE` while `trainId` is set makes the wagon grabbable by transfer requests and the legacy assign flow.
|
||||
**Fix:** reject `trainId`/`sequenceNumber` in update; when `wagon.trainId != null`, reject `currentYardId`/`status` changes (409 → point at train-builder endpoints).
|
||||
|
||||
### H3. `DELETE /wagons/:id` hard-deletes with no guard — destroyed real data during this audit *(live-confirmed)*
|
||||
**File:** `wagons/wagons.service.ts:136-139` — `this.wagonRepo.remove(wagon)` is a **hard** delete (repo standard is soft-delete via `BaseEntity.deletedAt`). No check for `trainId`, live-schedule pinning, or containers.
|
||||
**Repro (live):** I called `DELETE /api/wagons/{BW1-0009}` while it was `ASSIGNED` to my test train → `200`. Raw SQL then showed **the row physically gone** (`SELECT … WHERE id=… → 0 rows`). On delete, `train_set_wagons.physical_wagon_id` is `SET NULL` (a dispatched schedule silently loses its physical wagon) and `wagon_movements` is `CASCADE` (the audit ledger is destroyed).
|
||||
> **I recreated BW1-0009 via the API** (`POST /api/wagons`, new UUID `d9b578ed-…`, AVAILABLE @ KALITY) so the dev data count is whole again. The original UUID `905cad62-…` is unrecoverable (hard delete). See §10.
|
||||
**Fix:** block deletion when `trainId IS NOT NULL` or the wagon is pinned to a DRAFT/SCHEDULED/DISPATCHED schedule (reuse `train-builder`'s `isWagonPinnedToLiveSchedule`); switch to `softRemove` (add a partial unique index on `wagon_number WHERE deleted_at IS NULL`).
|
||||
|
||||
### H4. `DELETE /trains/:id` hard-deletes a built train and permanently strands its wagons
|
||||
**File:** `trains/trains.service.ts:57-60` — `trainRepo.remove(train)`, no active-schedule check (contrast `train-builder.service.ts:536-563 disband`, which blocks on live schedules and releases resources). Wagons' FK is `SET NULL`, so they keep `status = ASSIGNED` with `trainId = null` → **unusable forever** (attach requires `status === Available`). The fleet Trains CRUD page (`resources.ts:204`) points at this endpoint.
|
||||
**Fix:** delegate `TrainsService.remove` to `TrainBuilderService.disband` (block live schedules, reset wagons to Available, delete locomotive links), and use `softRemove`.
|
||||
|
||||
### H5. Legacy wagon assign: maintenance wagons assignable, silent theft, duplicate sequences
|
||||
**File:** `wagons/wagons.service.ts:141-164` (`POST /wagons/:id/assign-train`). Only guard is `status === Assigned`. Holes vs the builder's `attachWagons`: a `MAINTENANCE`/`DETAINED` wagon is accepted and flipped to ASSIGNED (erasing the flag); a wagon with `trainId` set but status≠ASSIGNED has its `trainId` overwritten (**steals it from another train's consist**, never resequenced); no wagon-yard vs train-yard check; no `train.status !== IN_SERVICE` check; caller `sequenceNumber` isn't collision-checked (no unique index on `(train_id, sequence_number)`).
|
||||
**Frontend echo:** `AssignWagonDialog.tsx:22-24` filters `w.status === Available || !w.trainId` — the `||` should be `&&`; today it offers maintenance/detained wagons.
|
||||
**Fix:** re-implement on builder rules (`Available && trainId === null && sameYard && train not InService`, sequence = max+1), or delete the endpoint and use `POST /train-builder/:id/wagons`.
|
||||
|
||||
### H6. `bulk-status` / yard-workspace "Free up" corrupts wagons physically in a train
|
||||
**File:** `wagons/wagons.service.ts:241-269` (`bulkSetStatus`, no `trainId` guard); UI `WagonYardWorkspaceModal.tsx:187,270-287,545-580` picks an arbitrary slice of ASSIGNED wagons and flips them AVAILABLE — including consist wagons (whose ASSIGNED means "coupled"). After the flip they still have `trainId` set but read AVAILABLE → transfer requests hand them out, `bulk-transfer` moves them, legacy assign re-homes them, while the builder consist still lists them.
|
||||
**Fix:** refuse to change status of wagons with `trainId IS NOT NULL`; exclude them from the modal's flip pools.
|
||||
|
||||
### H7. `bulk-transfer` relocates train-coupled wagons; legacy unassign bypasses the schedule pin
|
||||
**File:** `wagons/wagons.service.ts:180-234` (`bulkTransfer`, existence-only check → moves consist wagons to another yard) and `166-172` (`unassignFromTrain` frees a wagon with no `isWagonPinnedToLiveSchedule` check and leaves a sequence gap).
|
||||
**Fix:** reject `trainId IS NOT NULL` in `bulkTransfer`; run the builder's pinned-schedule query in `unassignFromTrain` and resequence, or delete in favor of `DELETE /train-builder/:id/wagons/:wagonId`.
|
||||
|
||||
### H8. Export capacity check-then-act race → train overbooking
|
||||
**File:** `bookings/booking-transition.service.ts:1093-1153` → `train-scheduling/booking-batch.service.ts:722-793` — `pickExportSchedule` reads `budget.fits(...)` then `reserve` (idempotency only, never re-checks capacity). No transaction/row-lock spans the check and the write (the code comment even calls the pre-check "rough").
|
||||
**Impact:** two staff accept two export bookings for the same near-full train concurrently → both pass `fits()`, both reserve → train exceeds locomotive pull weight / wagon slots.
|
||||
**Fix:** wrap check + reserve in a serializable transaction with `SELECT … FOR UPDATE` on the schedule (or a per-schedule advisory lock); re-verify `fits()` inside the lock.
|
||||
|
||||
### H9. Consolidation pairing race → one partner paired with two bookings
|
||||
**File:** `bookings.service.ts:494-543` + `bookings.repository.ts:213-261,287-309` — `findComplementaryConsolidationPartner` filters `consolidationPartnerId IS NULL`, `pairConsolidation` writes both sides, no lock between find and pair.
|
||||
**Impact:** two bookings both pick the same waiting partner → asymmetric pairing, shared-wagon capacity double-counted.
|
||||
**Fix:** `FOR UPDATE` on the candidate inside a transaction, re-assert `consolidationPartnerId IS NULL` on both before writing.
|
||||
|
||||
### H10. `import-operations` controller has no authorization
|
||||
**File:** `import-operations/import-operations.controller.ts` (whole file — only `@ApiTags`, no permission guard). Any authenticated portal user can `POST /import-operations/customs/{anyBookingId}/release-permitted`, assign customs risk, or mark duties/taxes paid on any booking.
|
||||
**Fix:** add `@BookingStaff(FREIGHT_PERMS…)` guards.
|
||||
|
||||
### H11. `interchange-documents` controller has no authorization
|
||||
**File:** `interchange-documents/interchange-documents.controller.ts` — `POST /generate-from-schedule`, `PATCH /:id/acknowledge|dispute|cancel` reachable by any authenticated user. These are outward-facing customs/port handover records.
|
||||
**Fix:** staff permission guards.
|
||||
|
||||
### H12. Anyone can sign anyone's contract as CUSTOMER
|
||||
**File:** `contracts/contracts.controller.ts:502-523` (asserts permission only for non-CUSTOMER roles) + `contract-transition.service.ts:795-816` (CUSTOMER branch checks only status + a **caller-supplied** `dto.otpPhone`/`dto.otp`). Any portal user signs another company's CONTRACT_READY contract with an OTP on their own phone → SIGNED_CUSTOMER, firing counter-sign/clearance-fee. `renew` (525) and `clearance/documents` (543) have the same gap.
|
||||
**Fix:** call `assertCustomerCanAccessContract`; verify OTP against the contract company's registered phone, not `dto.otpPhone`.
|
||||
|
||||
### H13. Any file downloadable by anyone with the UUID *(live-confirmed public)*
|
||||
**File:** `files/files.controller.ts:20-55` — the single global file-stream route is `@Public()`, no auth, no ownership, no expiry. All uploads funnel through it: driver documents, contract PDFs, company license / Fayda national-ID files.
|
||||
**Repro (live):** `GET /api/files/{uuid}` → `404` with no token (reached handler; a valid id streams the file). Control routes return `401`.
|
||||
**Fix:** require `JwtGuard` and authorize by the file's `resource`/`resourceId`, or serve via short-lived signed URLs (the code already has `filesService.signUrl`).
|
||||
|
||||
### H14. Duplicate `payment.succeeded` regresses advanced/cancelled bookings back to PAID
|
||||
**File:** `bookings/booking-invoice.service.ts:133-166` — the idempotency guard `// if (booking.paymentStatus === "PAID") return;` is **commented out**, then it unconditionally rewrites `status: "PAID"` and re-runs allocation. The relay is documented at-least-once and freight never dedupes `eventId` (the `PaymentWebhookEventEntity` is registered but unused).
|
||||
**Impact:** a replayed success on a `SCHEDULED`/`DISPATCHED` — or `CANCELLED` — booking force-rewrites it to PAID and re-fires side effects.
|
||||
**Fix:** restore the guard as a state-machine check (only advance from awaiting-payment statuses); persist processed `eventId`s.
|
||||
|
||||
### H15. Booking prices ignore the contract's frozen rate snapshots
|
||||
**File:** `bookings/booking-pricing.service.ts:123-229` prices exclusively from `ratesService.findLiveRates()`; nothing reads `contract_rate_snapshots` (only the clearance fee honors the freeze). `contract-booking.service.ts:289-295` comments "compute from contract unit rates" but calls the live-rate path.
|
||||
**Impact:** customer signs a contract at 1,916 USD/40ft, rates team raises the live rate to 2,300, the drawdown booking bills 2,300 — contradicting the signed contract PDF.
|
||||
**Fix:** in `computePriceForBooking`, when `booking.contractId` is set, resolve unit prices from that contract's snapshots (fall back to live only for un-frozen codes).
|
||||
|
||||
### H16. Cancelling a booking leaves its invoice open & payable; refund path is dead code
|
||||
**File:** `booking-transition.service.ts:519-543` (`cancel()` writes a note + `status: CANCELLED`, never cancels the open PREPAID invoice) and `billing.service.ts:654-672` (`markInvoiceAsRefunded` has **zero callers**).
|
||||
**Impact:** the cancelled booking's invoice stays payable in the portal; paying it fires `booking.invoice.paid` → flips the CANCELLED booking back to PAID (see H14). Money already collected on a later-cancelled flow has no refund mechanism.
|
||||
**Fix:** `cancel()`/`reject()` must cancel/expire the open invoices in the same transaction; wire `markInvoiceAsRefunded` to a real staff refund endpoint; make `advanceBookingOnPayment` refuse terminal-status bookings.
|
||||
|
||||
### H17. `cancelTrainSchedule` has no status guard — a DISPATCHED/ARRIVED train can be cancelled
|
||||
**File:** `train-scheduling/train-scheduling.service.ts:3226-3311` (unguarded at `train-scheduling.controller.ts:817`). Every sibling transition checks `status`; this one goes straight to the cancel transaction.
|
||||
**Impact:** cancelling a DISPATCHED train sets every pinned wagon's `currentYardId = originStationId` ("never left") while they're rolling; releases the locomotives out on this run to AVAILABLE; detaches IN_TRANSIT bookings while their cargo stays IN_TRANSIT. The frontend only shows Cancel for `DRAFT`/`SCHEDULED` (`TrainScheduleV2ListPage.tsx:439-469`) — the API enforces nothing.
|
||||
**Fix:** reject unless `['DRAFT','SCHEDULED'].includes(schedule.status)`.
|
||||
|
||||
### H18. `executeReschedule` is not transactional — a mid-flight failure strands the schedule half-rescheduled
|
||||
**File:** `scheduling-reschedule/scheduling-reschedule.service.ts:131-210` — four separately-committed steps (persist new date → unassign each displaced booking → `assignBookingsToSchedule` → audit event). Step 3 routinely throws (re-validates against the *new* date persisted in step 1). Result: date already moved, displaced bookings already gone, no audit event, `400` to staff.
|
||||
**Fix:** run the whole execute in one `dataSource.transaction`, threading the manager through unassign/assign.
|
||||
|
||||
---
|
||||
|
||||
## 5. MEDIUM — scheduling, bookings, cargo, containers
|
||||
|
||||
### M1. Cargo delivery never releases the container (counts itself)
|
||||
**File:** `cargoes/cargoes.service.ts:159-175` — sets `cargo.status = 'DELIVERED'` in memory, then counts `LOADED` cargo on the container *before saving*, so the cargo being delivered counts itself → `remaining >= 1` always → the container is **never** flipped back to AVAILABLE.
|
||||
**Fix:** save the cargo first, or exclude the current id: `count({ where: { containerId, status: 'LOADED', id: Not(cargo.id) } })`.
|
||||
|
||||
### M2. Cargo unload leaves the container marked LOADED
|
||||
**File:** `cargoes.service.ts:139-147` — `unloadCargo` sets cargo `UNLOADED` but never touches `container.status` (which `loadCargo` set to LOADED). Emptied containers read as in-use forever.
|
||||
**Fix:** on unload, if no remaining LOADED cargo references the container, reset it to AVAILABLE.
|
||||
|
||||
### M3. Container↔wagon assignment: no capacity/duplicate guard; status wrongly AVAILABLE
|
||||
**File:** `container-management/containers.service.ts:113-136` — `MAX(position)+1` check-then-act with no `(wagon_id, position)` unique constraint; no check that the container is already on another wagon (silently overwrites `wagonId`); sets `status = 'AVAILABLE'` for a container physically on a wagon (so it reads free for another assignment). *(There's also a dead `containers.service copy.ts` duplicate.)*
|
||||
**Fix:** reject when the container already has a `wagonId`; enforce wagon capacity; unique `(wagon_id, position)` + allocate in a transaction; use a distinct on-wagon status.
|
||||
|
||||
### M4. Interchange document state machine holes
|
||||
**File:** `interchange-documents/interchange-documents.service.ts:182-206` — `dispute` has **no status guard** (a CANCELLED or ACKNOWLEDGED doc can be flipped to DISPUTED); `acknowledge` guards only CANCELLED, so an already-DISPUTED doc can be quietly ACKNOWLEDGED (losing the dispute).
|
||||
**Fix:** restrict `dispute` to GENERATED/ACKNOWLEDGED; restrict `acknowledge` to GENERATED.
|
||||
|
||||
### M5. Interchange item weight mixes tons and kg in one column
|
||||
**File:** `interchange-documents.service.ts:238-361` — the `weight` column is `COALESCE`d from tons sources (`total_vgm_tons`, `cargo_total_weight_vgm`) *and* kg sources (`containers.max_gross_weight`, `cargoes.weight // kg`). Different line items in the same customs handover carry weights ~1000× apart.
|
||||
**Fix:** normalize every source to one unit before writing.
|
||||
|
||||
### M6. Cargo load/create allow weight over container capacity
|
||||
**File:** `cargoes.service.ts:114-137` — `loadCargo` sets `cargo.weight = dto.weight` (`@Min(0)` only), never compared to the container's `maxGrossWeight`, no aggregate across cargoes.
|
||||
**Fix:** verify `tare + sum(loaded) ≤ maxGrossWeight` (mind M5's units) and reject overflow.
|
||||
|
||||
### M7. Reschedule changes the departure date without any of `updateScheduleDate`'s validation
|
||||
**File:** `scheduling-reschedule.service.ts:151-157` vs `train-scheduling.service.ts:781-865` — the reschedule/maintenance path writes `scheduledDepartureDate` directly: no `PRE_WINDOW` check, no lead-window rejection, no re-derivation of `windowOpensAt/ClosesAt/Phase`, no route+day group re-anchor, and **past dates are accepted** (never compared to `now`; only the dialog checks client-side). Windows keep the timing computed for the OLD date.
|
||||
**Fix:** delegate to `updateScheduleDate` (or replicate its checks) and validate `newDepartureDate > now`.
|
||||
|
||||
### M8. `compareSchedulingPriority` sorts null-date bookings FIRST (comment says last)
|
||||
**File:** `scheduling/compare-scheduling-priority.util.ts:4-6,20-22` — null `scheduledDate` → `getTime()` falls back to `0` (epoch), ascending sort puts it first. In `previewReschedule` this decides who is retained when capacity is tight → a general-contract booking with no date outranks customers who booked a concrete slot.
|
||||
**Fix:** fall back to `Number.MAX_SAFE_INTEGER`, not `0`.
|
||||
|
||||
### M9. Assigning bookings never checks the booking's day matches the schedule's departure day
|
||||
**File:** `train-scheduling.service.ts:1226-1259` + `3321-3618` — the parity guard's comment claims day-fit is enforced "downstream", but `validateBookingsForScheduling` never reads `dto.scheduleDate` or compares `booking.scheduledDate`. A booking a customer picked for Jul 25 can be assigned to a train departing Jul 17, silently.
|
||||
**Fix:** add a violation (or `forceAssign` warning) when `eatDay(booking.scheduledDate) !== eatDay(schedule departure)`.
|
||||
|
||||
### M10. Partial `unassignBooking` frees the booking but not its wagon slots/totals
|
||||
**File:** `train-scheduling.service.ts:1492-1541` — `TrainSetWagon` slots and `TrainSet` aggregates are only reset when the train becomes fully empty. Remove 1 of 3 bookings → tonnage/length/wagonCount stay stale, empty slots stay RESERVED with wagons pinned, free-capacity under-reports (can hide the day from customers), dispatch sends the empty pinned wagons.
|
||||
**Fix:** after a partial unassign, release the emptied slots and recompute totals from surviving allocations.
|
||||
|
||||
### M11. Displaced-booking fallback leaves the booking still linked to the schedule
|
||||
**File:** `scheduling-reschedule.service.ts:159-168` — on unassign failure the `catch` only flips scheduling fields; it doesn't delete the `TrainScheduleBooking` link, clear `trainScheduleId`, or free allocations. The booking becomes ELIGIBLE for batch fills **and** still linked → double-booking; its stale `trainScheduleId` also blocks manual assignment elsewhere.
|
||||
**Fix:** in the fallback, delete the link + allocations and set `trainScheduleId: null` (or re-throw and abort).
|
||||
|
||||
### M12. Reschedule notifies "rescheduled to a new date" even when the date didn't change
|
||||
**File:** `scheduling-reschedule.service.ts:204-235` — `effectiveDeparture` falls back to the (never-null) existing date, so the `if (newDeparture)` branch always runs → a GOVERNMENT_PREEMPT rebalance with no date change SMS/email-blasts every retained customer "rescheduled to `<same date>`".
|
||||
**Fix:** `const effectiveDeparture = dto.newDepartureDate ? new Date(dto.newDepartureDate) : null;`
|
||||
|
||||
### M13. Maintenance reschedule endpoint bypasses DTO validation & drops caller bookings
|
||||
**File:** `scheduling-reschedule.controller.ts:56-65` (body typed as an intersection `PreviewRescheduleDto & { … }` → Nest emits `Object` metadata → **ValidationPipe is skipped**, so `newDepartureDate: "garbage"` and missing arrays reach the service) + `scheduling-reschedule.service.ts:257-283` (preview uses `currentIds.length ? currentIds : dto.incomingBookingIds`, so caller-supplied incoming ids are dropped on a non-empty train, then execute re-runs with `dto.incomingBookingIds` → the displaced-set equality check can 400).
|
||||
**Fix:** real `MaintenanceRescheduleDto extends PreviewRescheduleDto` with `@IsDateString() newDepartureDate`; merge `currentIds ∪ dto.incomingBookingIds` for both preview and execute.
|
||||
|
||||
### M14. Routes editable (milestones deleted, endpoints swapped) while live schedules reference them
|
||||
**File:** `routes/routes.service.ts:108-140` — `update()` deletes+rewrites milestones and origin/destination with no check for DRAFT/SCHEDULED/DISPATCHED schedules on the route. Schedules read the corridor live afterward (sub-leg validation, checkpoints, customer day pool), so a reroute silently invalidates boarding bookings and renumbers stations mid-run.
|
||||
**Fix:** reject milestone/endpoint changes when any non-terminal schedule references the route (allow status-only edits).
|
||||
|
||||
### M15. Workspace capacity meter sums locomotive limits; API caps at the weakest locomotive
|
||||
**File:** `.../components/trainScheduling/ScheduleWorkspacePanel.tsx:98-109` (`reduce(sum + maxPullWeightTons)`) vs `train-capacity.util.ts:242-260` + service `1349-1389` (`minLocomotiveLimits` — the weakest locomotive caps the train; gate is cargo **+ consist tare**). Two 3500T locos → the meter shows 7000T and "43% full" while the API already rejects adds at ~3500T gross; the overfill warning fires on the wrong threshold.
|
||||
**Fix:** capacity = `min(maxPullWeightTons) + min(overageToleranceTons)`, and include consist tare in `used`.
|
||||
|
||||
### M16. Vehicles under maintenance stay assignable; maintenance never changes availability
|
||||
**File:** `maintenance/*` never writes `vehicle.status`/availability; `maintenance.service.ts:36-46` COMPLETED doesn't restore anything; assignment paths check only availability, never `VehicleStatus.MAINTENANCE`.
|
||||
**Impact:** a vehicle in the shop can be dispatched; marking a vehicle MAINTENANCE doesn't block assignment.
|
||||
**Fix:** on maintenance start set the vehicle unavailable, on completion restore, and reject MAINTENANCE/OUT_OF_SERVICE/RETIRED at assignment.
|
||||
|
||||
### M17. Fuel purchases accept negative/zero quantities, no duplicate guard
|
||||
**File:** `fuel/dto/create-fuel-purchase.dto.ts:11-15` (`liters`, `costPerLiter` are `@IsNumber()` only) → `fuel.service.ts:18` `totalCost = liters * costPerLiter`. Negative liters → negative monthly totals & averages, poisoning Financial Reports/Fleet Dashboard; no `(vehicleId, receiptNumber)` uniqueness → double-counting.
|
||||
**Fix:** `@IsPositive()` on both; reject duplicate `(vehicleId, receiptNumber)`.
|
||||
|
||||
### M18. Public OTP verify: no expiry, no rate limit, no attempt cap, replayable
|
||||
**File:** `otp/otp.service.ts:94-120` (`verifyOtp`, exposed `@Public()` at `otp.controller.ts:22-63`) — a 6-digit code (1e6 space) with unlimited attempts, no age check, and only flagged `verified=true` on success (the same code keeps working). The hardened `verifyOtpForAction` (TTL + 5-attempt cap + delete-on-success) exists but this route doesn't use it.
|
||||
**Fix:** give `verifyOtp` the same TTL/attempt-cap/consume semantics; rate-limit the public OTP routes.
|
||||
|
||||
### M19. OTP generated with `Math.random()` (not a CSPRNG)
|
||||
**File:** `otp/otp.service.ts:27-29` — this gates password reset (`forgot-password.service.ts:134`) and contract-signature sudo. Predictable codes weaken account-takeover resistance.
|
||||
**Fix:** `crypto.randomInt(100000, 1000000)`.
|
||||
|
||||
### M20. OTP send is public & unthrottled — SMS/email bombing + counter reset
|
||||
**File:** `otp/otp.controller.ts:33-42` (`@Public() POST /otp/send`) + `otp.service.ts:35-88` (each send does `actionAttempts.delete(...)`, resetting the in-memory brute-force counter — attacker-controllable, and per-process anyway).
|
||||
**Fix:** rate-limit per target + per IP; move the attempt counter to persistent storage.
|
||||
|
||||
### M21. Email notifications silently discarded while reporting success
|
||||
**File:** `notifications/strategies/notification.email.strategy.ts:8-11` — `send()` logs and `return false;` (stub). `notifications.service.ts:25-32 directSend` awaits it, logs `is sent - false`, and neither throws nor surfaces the failure. Every email notification (booking lifecycle, contracts, companies, booking-window) silently never sends.
|
||||
**Fix:** implement the strategy (or route through the working `EmailClientService`); make `directSend` treat `false`/throw as an observable failure.
|
||||
|
||||
### M22. Rate "CEO approval" is self-approvable (no segregation of duties)
|
||||
**File:** `rule-engine/services/rates.service.ts:186-208` + `rates.controller.ts:61-76` — `submit` and `approve` share the identical `@RuleEngineManage('rates')` permission, and `approve` never checks `approverUserId !== proposedByStaffId`. One staffer can draft→submit→approve LIVE. (Same in `priority-rule-change-requests.service.ts:78-112`.)
|
||||
**Fix:** distinct approver permission + reject self-approval.
|
||||
|
||||
### M23. Uploads accept arbitrary type & unbounded size
|
||||
**File:** `drivers/drivers.controller.ts:74-82` (`AnyFilesInterceptor()`, no `limits`/`fileFilter`) + `files/files.service.ts:36-55` (stores whatever mime/size). `file.buffer` held in memory → DoS; executables/active-HTML then served inline via the public files route (H13). The `file-upload-settings` config exists but isn't enforced.
|
||||
**Fix:** Multer `limits.fileSize` + mime allowlist (driven by file-upload-settings), validated in `FilesService.upload`.
|
||||
|
||||
### M24. Incidents controller: full CRUD + IDOR for any authenticated user
|
||||
**File:** `incidents/incidents.controller.ts:17-69` — no permission guard on any route (sibling fleet modules all use `@BookingStaff`). A portal customer can read any driver's incident history by `driverId` and create/alter/delete incident records.
|
||||
**Fix:** class-level `@BookingStaff(FREIGHT_PERMS.incidents.view)` + per-write permissions.
|
||||
|
||||
### M25. Tracking timeline IDOR
|
||||
**File:** `tracking/tracking.controller.ts:11-17` — `GET /:consignmentId` has no guard/ownership check; any logged-in user reads any consignment's full movement history by iterating UUIDs.
|
||||
**Fix:** permission guard + scope to the caller's company.
|
||||
|
||||
### M26. Clearance-fee gate silently waived when the fee snapshot is missing
|
||||
**File:** `contracts/clearance-fee.service.ts:80-87` — `gateApplies` returns `false` (skipping the prepay gate, warn-log only) whenever no `CUSTOMS_CLEARANCE` snapshot line exists, and `contract-pricing.service.ts:262-284` falls back to a stale stored breakdown. A customs contract whose price predates the fee feature ships clearance for free.
|
||||
**Fix:** for `customsClearingEnabled` contracts, hard-fail counter-sign/shipment-request when no fee line resolves.
|
||||
|
||||
### M27. Manual settlement / `updateStatus` gaps in the invoice state machine
|
||||
**File:** `billing.service.ts:577-609` (`recordPayment` guards Cancelled/Refunded/Paid but not Draft/Expired → an expired invoice can be settled at the counter, resurrecting a released flow) and `889-907` (`updateStatus` accepts **any** target including Paid without touching `paidAmount`/`balanceAmount` → a PAID invoice with a full outstanding balance).
|
||||
**Fix:** add Draft/Expired to the reject list; restrict `updateStatus` to the Draft→Issued transition it's actually used for.
|
||||
|
||||
---
|
||||
|
||||
## 6. LOW — hardening & latent bugs
|
||||
|
||||
- **L1. Decommission ignores coupling** — `locomotives.service.ts:121-133` sets OUT_OF_SERVICE with no `TrainLocomotive` check *(live-confirmed: decommissioned a coupled loco, 200)*. Train keeps hauling with a dead loco on paper; `capacityTons` not recomputed. → link-table guard.
|
||||
- **L2. Builder accepts ASSIGNED/UNAVAILABLE locomotives** — `train-builder.service.ts:653` blocks only OUT_OF_SERVICE/MAINTENANCE; a loco out on a dispatched run (ASSIGNED) can be coupled to a new train. → allowlist `AVAILABLE/IMPORT_READY/EXPORT_READY`.
|
||||
- **L3. Legacy `PATCH /trains/:id`** — `trains.service.ts:50-55` `Object.assign` lets `status`/`capacityTons`/`code`/`trainNumber` be rewritten (unfreeze a dispatched train, break the weakest-loco capacity, code/number clashes → 500 or silent collision). → strip those fields; run the builder's clash query.
|
||||
- **L4. `PATCH /train-builder/:id/details` skips even/odd run-number rule** — `update-train-details.dto.ts:17-29` lacks the `@Matches(/…[13579]$/ | …[02468]$/)` the build DTO enforces → can set IMPORT number odd. → copy the decorators.
|
||||
- **L5. Legacy reorder ignores `trainId`** — `wagons.service.ts:271-287` (`_trainId` unused) renumbers any wagon list to `1..n` with no set-equality check → duplicate sequences on other trains. → validate set equality vs `find({ where: { trainId } })`.
|
||||
- **L6. Wagon type soft-deletable while in use** — `wagon-types.service.ts:114-117` no reference check; soft-deleted type → `wagon.wagonType` loads null → builder length/tare math silently zeroes. → block when wagons reference it.
|
||||
- **L7. Facilities create/update are dead** — `facilities/dto/*.dto.ts` are plain interfaces (no decorators) under a `forbidNonWhitelisted` pipe → every non-empty body 400s, empty body 500s. → decorate the DTOs + code-uniqueness check.
|
||||
- **L8. Yard soft-delete has no reference guard** — `rule-engine/services/yards.service.ts:62-66` strands trains/locos/wagons parked there (their `current_yard_id` scalar survives but joins miss). → count references, 409.
|
||||
- **L9. Wagon create incoherent state** — `wagons.service.ts:27-37` accepts `trainId` while status defaults AVAILABLE (violates attached⇒ASSIGNED); duplicate `wagonNumber` → 500 not 409. → drop `trainId`/`sequenceNumber` from create DTO, uniqueness pre-check.
|
||||
- **L10. Locomotive code generation is a read-scan race** — `locomotives.service.ts:40-48` scans all rows for max `LOCO-NNN`; concurrent creates collide → 500. → `MAX(SUBSTRING…)` + probe loop or a sequence.
|
||||
- **L11. `Train.capacityTons` stale after a coupled loco's pull limit is edited** — `locomotives.service.ts:87-119` has no recompute hook; list vs detail disagree. → recompute for trains found via `TrainLocomotive`.
|
||||
- **L12. `GET /facilities/:id` returns `200 null`, `DELETE` succeeds for unknown ids** — `facilities.service.ts:20-30` (every sibling 404s). → throw NotFound.
|
||||
- **L13. Import customs flow has no ordering/terminal guards** — `import-operations.service.ts:102-210`: duties-paid before notify, risk changed after completion, no terminal lock. → state + `completedAt` guards.
|
||||
- **L14. Consignment create doesn't validate the referenced booking** — `consignments.service.ts:14-17` persists the DTO directly (`bookingId` only `@IsUUID`). → existence check.
|
||||
- **L15. Incident & maintenance status transitions unvalidated** — `incidents.service.ts:64-71`, `maintenance.service.ts:36-46` accept any status (REPORTED→CLOSED, SCHEDULED→COMPLETED). → allowed-transition checks.
|
||||
- **L16. Maintenance cost fields accept negatives** — `maintenance/dto/create-maintenance.dto.ts` `estimatedCost/actualCost/costAmount` no `@Min(0)`. → add it.
|
||||
- **L17. Procurement depreciation accepts inverted money** — `procurement/dto/procurement.dto.ts:89-99` (no `@Min(0)`) + `procurement.service.ts:120-131`: `salvageValue > cost` → negative monthly depreciation → `bookValue` grows unbounded. → `@Min(0)` + `salvage <= cost`.
|
||||
- **L18. `notification-inbox` fan-out is sequential N+1** — `notification-inbox.service.ts:62-64` per-recipient create+countUnread+findOne. → batch/parallelize (it's fire-and-forget, so slow not broken).
|
||||
- **L19. GPS history `limit` unguarded vs NaN/negative** — `gps-tracking.controller.ts:38` passes `parseInt` through; `?limit=abc`→NaN, `?limit=-5`→negative take. → clamp to a positive int.
|
||||
- **L20. Hardcoded default JWT secret** — `@edr/api-common` `shared-auth.module.js` falls back to a public constant if `JWT_SECRET` is unset (mitigated by the session-row check). → ensure `JWT_SECRET` always set in the environment.
|
||||
|
||||
---
|
||||
|
||||
## 7. Backoffice / portal frontend mismatches (already folded into findings above)
|
||||
|
||||
- **Locomotive/Wagon fleet edit forms PATCH the whole form object** (`FleetResourcePage.tsx:301`, `resources.ts:168-169,273-279`) → the accidental trigger for H1/H2.
|
||||
- **`AssignWagonDialog.tsx:22-24`** — `status === Available || !w.trainId` should be `&&` (H5).
|
||||
- **`WagonYardWorkspaceModal.tsx`** — "Assigned→Available" flip picks an arbitrary slice including coupled wagons (H6).
|
||||
- **`ScheduleWorkspacePanel.tsx:98-109`** — capacity meter sums loco limits instead of the weakest (M15).
|
||||
- **`InvoiceDetailPage.tsx:129-131` / `PayClearanceFeeButton.tsx:121-125`** — pay button shows `totalAmount`, backend charges `balanceAmount`; the correct `amountDue` line is commented out → a 60%-paid invoice tells the customer they'll pay the full total. Also a leftover `console.log(paymentMethod)` at line 71.
|
||||
- **Reflected XSS on the public checkout page** — `billing/payment.controller.ts:100-103,156-176` interpolates provider error `message`/`status`/`intentId` unescaped into `@Public()` HTML. → HTML-escape or return a generic message.
|
||||
- **Portal tracking/consignments pages are still mock-backed** (`portal/src/pages/tracking/shipments.mock.ts`, `consignments.mock.ts`) — they show mock data, not live consignments.
|
||||
|
||||
---
|
||||
|
||||
## 8. What is working correctly (verified, not bugs)
|
||||
|
||||
So the report is balanced — these were checked and are sound:
|
||||
|
||||
- **Train-builder same-yard enforcement** at build and attach (rejected my cross-yard attempts with clear 400s).
|
||||
- **Double-coupling a locomotive** → `409 already coupled to train …` (guard works).
|
||||
- **`train-builder` yard change** correctly relocates the whole consist (locos + wagons follow).
|
||||
- **Dispatch / arrive / finalize** status guards, dispatch train-number pooling with pessimistic locks, route direction derivation and segment ordering, EAT-timezone math in `batch-window.util.ts` (no string date comparisons).
|
||||
- **Booking cancellation** can't leak reserved capacity (only pre-reservation statuses cancellable); approval-step sequencing; `allocateContainers` and interchange generation are properly transactional.
|
||||
- **GT06 GPS codec** coordinate/CRC/UTC decoding correct.
|
||||
- **notification-inbox WebSocket auth**, **signatures**, **backoffice role management** (blocks reserved roles), **forgot-password/customer-reset** (use the hardened OTP + single-use IAM tickets) — all scope correctly.
|
||||
- **Clearance-fee migration 2260** itself is sound (nullable/defaulted, `IF NOT EXISTS`); the gate enforcement on document upload is correct on both contract and booking sides (the bugs are around it — M26, and C1–C3 letting the fee be "paid" for free).
|
||||
|
||||
---
|
||||
|
||||
## 9. Boot-log noise (environment, not code bugs)
|
||||
|
||||
- **SMS + Email over RabbitMQ fail:** `Handshake terminated by server: 403 (ACCESS-REFUSED) … Login was refused using authentication mechanism PLAIN` — broker credentials for the dev environment. (Compounds M21: email is doubly dead — stub strategy *and* no broker.)
|
||||
- **Swagger duplicate-DTO warnings:** `UpdateProfileDto`, `RequestChangesDto`, `SignContractDto` each defined twice with different schemas ("will throw in the next major version"). Rename the duplicates.
|
||||
- **`LegacyRouteConverter` warning** on `/api/*` — path-to-regexp v6 wants `/api/*path`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Test-data side effects from this audit (please review)
|
||||
|
||||
I drove real writes against `edr_freight`. Net state:
|
||||
|
||||
1. **Created train `TR-00002`** (`exportTrainNumber 9901`, `importTrainNumber 9902`, name "QA-TEST-TRAIN") — then **deleted it** (cleaned up).
|
||||
2. **Destroyed wagon `BW1-0009`** (original UUID `905cad62-1b12-46a0-9268-bacbf115e787`) via the hard-delete bug (H3). **I recreated it via the API** — new UUID `d9b578ed-e65a-48f6-9ae2-fb8239451460`, `AVAILABLE`, in KALITY, same wagon type. The wagon count is whole again; only the UUID changed (its old `wagon_movements` ledger was cascade-deleted and cannot be recovered).
|
||||
3. **Locomotives briefly moved** during the yard-divergence repro (LOCO-004, LOCO-023, LOCO-025) — **all restored** to KALITY / their original yards. LOCO-004 was briefly set OUT_OF_SERVICE by the decommission test and **restored to AVAILABLE**.
|
||||
|
||||
No other records were mutated. (A raw-SQL restore of the original wagon UUID was intentionally **not** performed — the DB-write guard blocked it, and re-inserting via the API is the correct, app-logic-respecting cleanup.)
|
||||
|
||||
---
|
||||
|
||||
## 11. Prioritized fix roadmap
|
||||
|
||||
**Do first (security / money — a customer or attacker can exploit these today):**
|
||||
1. C1 — remove the DEMO auto-settle in `billing.service.ts:973`.
|
||||
2. C2 / H13 — auth-guard `mark-paid`, `payments/initiate`, `payments/checkout`, `payments/receipt`, and `files/:id`.
|
||||
3. C3 + C4 — unify currency units and enforce amount verification at settlement.
|
||||
4. C5 — enable Telebirr/D-Money webhook signature checks.
|
||||
5. C6 / H10 / H11 / H12 / M24 / M25 — add ownership/permission guards to booking writes, import-operations, interchange-documents, contract-sign, incidents, tracking.
|
||||
|
||||
**Do next (data integrity — the consist family, one structural fix):**
|
||||
6. **H1–H7 + L1–L2**: the `TrainLocomotive`/`wagon.trainId` coupling guard on every locomotive/wagon mutation (this is your reported bug and its whole family). Switch `DELETE` to soft-delete.
|
||||
7. H8/H9 capacity & consolidation locks; H17 cancel-status guard; H18 transactional reschedule.
|
||||
|
||||
**Then (correctness):** M1–M27 (container release, weight units, scheduling day/priority/notification bugs, OTP hardening, self-approval).
|
||||
|
||||
**Finally (hardening):** the L-series + the frontend dirty-field / display fixes in §7.
|
||||
|
||||
---
|
||||
|
||||
*Report generated from a live run of `pnpm run dev:freight` plus a full read of the in-scope modules. Every "live-confirmed" item has a reproduced request/response above; every code finding cites `file:line`.*
|
||||
|
||||
---
|
||||
|
||||
# ADDENDUM — Fixes Applied (2026-07-16)
|
||||
|
||||
All **18 High + 27 Medium** findings were fixed. Critical (C1–C6) and Low (L-series) were **left untouched** per the request (except L1, which shares the H1 guard). Applied across ~35 backend files + 3 frontend files via 6 partitioned edit passes.
|
||||
|
||||
**Verification status**
|
||||
- `turbo run type-check --filter=@edr/freight-api` → **clean (0 errors)**.
|
||||
- Web apps: freight-portal clean; freight-backoffice fails **only** on the pre-existing `user-management/web-Management/**` errors (documented, none in edited files).
|
||||
- `scheduling-reschedule.service.spec` → **5/5 pass** (updated for the H18 transaction + a future-dated fixture for the new M7 past-date guard).
|
||||
- App **boots clean** on 3030; migration `2280000000000-WagonNumberPartialUnique` applied and recorded (partial unique index `UQ_wagons_wagon_number_active` verified live).
|
||||
|
||||
**Live-verified fixes (real requests against the running app)**
|
||||
- **H1** — `PATCH /locomotives/:id` on a coupled loco → `409 "coupled to train TR-00002; move the train instead"` (yard) and `409 "detach it before changing its status"` (status). *This is your reported bug — now behaves exactly as requested.*
|
||||
- **L1** — decommission coupled loco → `409`.
|
||||
- **H2** — `PATCH /wagons/:id` yard on coupled wagon → `409`; sending `trainId` → `400 "property trainId should not exist"`.
|
||||
- **H3** — `DELETE /wagons/:id` on coupled wagon → `409`, wagon **survives** (data-loss bug closed); delete now uses `softRemove`.
|
||||
- **H4** — `DELETE /trains/:id` on a built train → frees wagon (AVAILABLE, unlinked) + both locos, **soft-deletes** the train (tombstone row, not stranded).
|
||||
|
||||
**What each pass changed (high level)**
|
||||
- **Fleet (H1–H7, L1):** coupling guard (`train_locomotives` / `wagon.trainId`) on every legacy loco/wagon mutation; `assignToTrain` tightened to builder rules; `bulk-status`/`bulk-transfer`/`unassign` refuse coupled/pinned wagons; hard-delete → soft-delete (+ migration); frontend `AssignWagonDialog` `||`→`&&`, workspace modal excludes coupled wagons.
|
||||
- **Scheduling (H17, M8–M10, M14, M15):** cancel status-guard; null-date sort fixed; booking-day match check; partial-unassign now releases slots + recomputes TrainSet totals; route edit blocked while live schedules reference it; capacity meter uses weakest loco.
|
||||
- **Reschedule (H18, M7, M11–M13):** date+event wrapped in one transaction; `newDepartureDate > now` guard; real `MaintenanceRescheduleDto` (validation no longer skipped); merged incoming-booking set; retained-customer notice only when the date actually moves; displaced-fallback clears `trainScheduleId`.
|
||||
- **Bookings/Billing/Contracts (H8, H9, H12, H14–H16, M26, M27):** export-capacity + consolidation now lock the row (`FOR UPDATE`) and re-check before reserving; payment idempotency guard restored (won't resurrect CANCELLED/advanced); cancel/reject expire open invoices; contract-sign requires ownership + verifies OTP against the company's registered phone; pricing honors frozen contract-rate snapshots; clearance-fee gate hard-fails instead of waiving; invoice state-machine gaps closed.
|
||||
- **Cargo/Containers/Customs (M1–M6, H10, H11):** container release counts fixed (exclude-self); unload frees container; load rejects over-capacity; container→wagon assign rejects double-assign + wraps position in a txn; interchange state-machine guarded; interchange item weights normalized to tons; import-operations + interchange-documents controllers now permission-guarded.
|
||||
- **Ops/Auth/Files (H13, M16–M25):** file download requires auth; incidents + tracking guarded; maintenance flips vehicle status/availability; fuel rejects non-positive + duplicate receipts; OTP uses CSPRNG + TTL/attempt-cap/consume-on-success, send no longer resets the brute-force counter; email strategy implemented + failures surfaced; rate/priority self-approval blocked; driver uploads size/type-limited.
|
||||
|
||||
**Residual / partial (flagged in code with TODOs — intentional, safe)**
|
||||
- **H18** cross-service (un)assign calls still run their own transactions (can't thread the manager without editing the scheduling core); date+event are atomic.
|
||||
- **M7** kept the raw date write + `>now` guard; booking-window fields are not re-derived (delegating to `updateScheduleDate` would wrongly require `PRE_WINDOW`).
|
||||
- **M11** best-effort detach (clears `trainScheduleId`); the link-row/allocation delete lives in the scheduling core.
|
||||
- **M13** format is enforced; `newDepartureDate` is validated-when-present but not strictly required (parent DTO marks it optional).
|
||||
- **M15** meter capped at weakest loco; consist tare isn't available client-side.
|
||||
- **M16** maintenance sets vehicle status; the assignment-side reject lives in the excluded first/last-mile modules (out of scope).
|
||||
- **M20** counter-reset removed; per-IP/per-target throttling is a TODO (no Throttler in the codebase yet).
|
||||
- **M22** self-approval blocked; a distinct CEO/approver permission is recommended (TODO).
|
||||
- **M24** reused real `drivers.*` permission keys (no `incidents:*` key exists yet — TODO to add one).
|
||||
- **M25** view-guarded; company-scoping the query is a TODO.
|
||||
- **H13** download now authenticated; ownership-by-resource + signed-URL previews are the next step (inline previews that relied on anonymous access will 401 until the frontend uses `signUrl`).
|
||||
- **H15** frozen rates cover base rail + surcharges + first/last-mile; a rare container-fallback line keeps the live rate.
|
||||
- **M3** position race narrowed by a txn; a `(wagon_id, position)` unique index is the full fix (TODO).
|
||||
|
||||
**Not committed.** All changes are in the working tree only.
|
||||
@@ -0,0 +1,70 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Wagons are now soft-deleted (deleted_at) instead of hard-deleted. The plain
|
||||
* UNIQUE on wagon_number would keep a retired wagon's number reserved forever
|
||||
* and block ever re-registering that number. Swap it for a PARTIAL unique index
|
||||
* that only constrains live rows (deleted_at IS NULL); soft-deleted wagons no
|
||||
* longer occupy their number.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so this is also
|
||||
* hand-applied there. The DO blocks + IF EXISTS/IF NOT EXISTS keep it
|
||||
* idempotent whether the original uniqueness is the auto-named column
|
||||
* constraint (wagons_wagon_number_key) or a TypeORM-named UQ_* constraint/index.
|
||||
*/
|
||||
export class WagonNumberPartialUnique2280000000000 implements MigrationInterface {
|
||||
name = 'WagonNumberPartialUnique2280000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop any UNIQUE constraint on freight.wagons(wagon_number), whatever it is
|
||||
// named (dropping the constraint also drops its backing index).
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE con_name text;
|
||||
BEGIN
|
||||
FOR con_name IN
|
||||
SELECT conname
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'freight.wagons'::regclass
|
||||
AND contype = 'u'
|
||||
AND pg_get_constraintdef(oid) ILIKE '%(wagon_number)%'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE freight.wagons DROP CONSTRAINT IF EXISTS %I', con_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Drop any standalone (non-partial) unique index on wagon_number too.
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE idx_name text;
|
||||
BEGIN
|
||||
FOR idx_name IN
|
||||
SELECT c.relname
|
||||
FROM pg_index i
|
||||
JOIN pg_class c ON c.oid = i.indexrelid
|
||||
WHERE i.indrelid = 'freight.wagons'::regclass
|
||||
AND i.indisunique
|
||||
AND i.indpred IS NULL
|
||||
AND c.relname <> 'UQ_wagons_wagon_number_active'
|
||||
AND pg_get_indexdef(i.indexrelid) ILIKE '%(wagon_number)%'
|
||||
LOOP
|
||||
EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// Live wagon numbers stay unique; soft-deleted rows are exempt.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_wagons_wagon_number_active"
|
||||
ON freight.wagons (wagon_number)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// No-op: re-adding a plain UNIQUE would fail whenever two soft-deleted
|
||||
// wagons share a number, and the partial index is strictly safer. Left in
|
||||
// place intentionally.
|
||||
}
|
||||
}
|
||||
@@ -602,6 +602,19 @@ export class BillingService {
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException("Invoice is already fully paid.");
|
||||
}
|
||||
// M27: a Draft invoice is not yet issued and an Expired invoice's pay
|
||||
// window has closed — neither is payable. Without these guards a payment
|
||||
// could settle an unissued draft or a lapsed invoice.
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
throw new BadRequestException(
|
||||
"Cannot pay a draft invoice — it must be issued first.",
|
||||
);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Expired) {
|
||||
throw new BadRequestException(
|
||||
"Cannot pay an expired invoice — its payment window has closed.",
|
||||
);
|
||||
}
|
||||
if (round2(input.amount) > Number(invoice.balanceAmount)) {
|
||||
throw new BadRequestException(
|
||||
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
|
||||
@@ -891,6 +904,20 @@ export class BillingService {
|
||||
status: Freight.InvoiceStatus,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
// M27: this is the blunt "issue a draft" override — it stamps `issuedAt` but
|
||||
// does NOT touch paidAmount/balanceAmount. Its only legitimate use is the
|
||||
// Draft → Pending/Issued issue transition. It must NEVER mark an invoice
|
||||
// Paid/Refunded/Cancelled/Expired (or PartiallyPaid/Overdue): those carry
|
||||
// balance implications and must go through the dedicated settlement methods
|
||||
// (recordPayment / markInvoiceAsRefunded / cancelInvoice / expirePayable).
|
||||
if (
|
||||
status !== Freight.InvoiceStatus.Pending &&
|
||||
status !== Freight.InvoiceStatus.Issued
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`updateStatus only issues an invoice (→ PENDING/ISSUED); use the dedicated settlement methods to set ${status}.`,
|
||||
);
|
||||
}
|
||||
const mg = manager ?? this.dataSource.manager;
|
||||
const invoice = await mg.findOne(Invoice, {
|
||||
where: {
|
||||
|
||||
@@ -103,8 +103,35 @@ export class PaymentController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-escape a value interpolated into the public checkout pages. These
|
||||
* pages are served unauthenticated and the interpolated values (provider
|
||||
* error messages, status strings, intent ids, redirect URLs) can carry
|
||||
* attacker-influenced input — unescaped they are a reflected-XSS sink.
|
||||
*/
|
||||
private escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
private buildRedirectHtml(url: string): string {
|
||||
const escaped = url.replace(/\"/g, """);
|
||||
// Only http(s) URLs may be used as a redirect target — a javascript:
|
||||
// URL would execute in the victim's browser from the <a>/location.href.
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return this.buildErrorHtml("Invalid payment redirect URL");
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return this.buildErrorHtml("Invalid payment redirect URL");
|
||||
}
|
||||
const escaped = this.escapeHtml(url);
|
||||
const jsEscaped = JSON.stringify(url);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -126,12 +153,14 @@ export class PaymentController {
|
||||
<p>Redirecting to payment provider…</p>
|
||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||
</div>
|
||||
<script>window.location.href = "${escaped}";</script>
|
||||
<script>window.location.href = ${jsEscaped};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildStatusHtml(status: string, intentId: string): string {
|
||||
private buildStatusHtml(rawStatus: string, rawIntentId: string): string {
|
||||
const status = this.escapeHtml(rawStatus);
|
||||
const intentId = this.escapeHtml(rawIntentId);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -153,7 +182,8 @@ export class PaymentController {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildErrorHtml(message: string): string {
|
||||
private buildErrorHtml(rawMessage: string): string {
|
||||
const message = this.escapeHtml(rawMessage);
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
@@ -119,6 +119,26 @@ export class BookingInvoiceService {
|
||||
return this.billing.updateStatus(invoiceId, status, manager);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire the booking's currently-open prepaid invoice when the booking is
|
||||
* cancelled or rejected — the counterpart to the pay-window-expiry path
|
||||
* (which also calls {@link BillingService.expirePayable}). Stops a terminated
|
||||
* booking from leaving a payable invoice open. No-op when the booking has no
|
||||
* open invoice (never invoiced, already paid/cancelled/expired). Pass a
|
||||
* caller `manager` to enlist in its transaction.
|
||||
*/
|
||||
expireOpenInvoices(
|
||||
bookingId: string,
|
||||
manager?: EntityManager,
|
||||
): Promise<Invoice | null> {
|
||||
return this.billing.expirePayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
"PREPAID",
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||
@@ -138,7 +158,32 @@ export class BookingInvoiceService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// if (booking.paymentStatus === "PAID") return;
|
||||
|
||||
// Idempotency + state-machine guard (restored). The prepaid-invoice paid
|
||||
// event can be delivered more than once (retries / re-emit), and a booking
|
||||
// may have moved on or been terminated between invoicing and settlement.
|
||||
// Only advance one that is still awaiting payment: no-op when already PAID,
|
||||
// and refuse to advance a booking in a terminal/advanced status
|
||||
// (CANCELLED/REJECTED/EXPIRED or already past the payment gate) so we never
|
||||
// rewrite its status or re-run allocation.
|
||||
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||||
return;
|
||||
}
|
||||
const TERMINAL_OR_ADVANCED_STATUSES: string[] = [
|
||||
"CANCELLED",
|
||||
"REJECTED",
|
||||
"EXPIRED",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"CONTRACT_CLOSED",
|
||||
];
|
||||
if (TERMINAL_OR_ADVANCED_STATUSES.includes(booking.status)) {
|
||||
this.logger.warn(
|
||||
`Skipping advance of booking ${bookingId} on payment: status ${booking.status} is terminal/advanced.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
AppliedCargoModifier,
|
||||
@@ -128,11 +129,18 @@ export class BookingPricingService {
|
||||
const isEtbBooking = paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
|
||||
// H15: a booking created under a contract prices from that contract's FROZEN
|
||||
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
|
||||
// once and threaded through the line builders; each rate code that has a
|
||||
// snapshot uses it, and any code without one falls back to the live rate.
|
||||
// Non-contract bookings resolve to null and keep the live-rate path.
|
||||
const frozenRates = await this.loadFrozenContractRates(booking);
|
||||
|
||||
const lineItems: PriceLineItemDto[] = [];
|
||||
let total = 0;
|
||||
|
||||
const { lineItems: baseLines, usedRates: baseRates } =
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput);
|
||||
await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
|
||||
for (const line of baseLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -141,7 +149,7 @@ export class BookingPricingService {
|
||||
// First / last mile trucking — billed per the rate's unit (km / container /
|
||||
// ton / flat), only for legs the booking actually carries.
|
||||
const { lineItems: mileLines, usedRates: mileRates } =
|
||||
await this.computeFirstLastMileLines(booking, evalInput);
|
||||
await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
|
||||
for (const line of mileLines) {
|
||||
lineItems.push(line);
|
||||
total += line.amount;
|
||||
@@ -153,15 +161,14 @@ export class BookingPricingService {
|
||||
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price.
|
||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||
// currency amount, so it is snapshot-independent).
|
||||
const quantity =
|
||||
unit === 'FLAT' || unit === 'PER_INVOICE'
|
||||
? 1
|
||||
@@ -171,6 +178,26 @@ export class BookingPricingService {
|
||||
? Math.max(1, Math.round(usdAmount / unitUsd))
|
||||
: 1;
|
||||
|
||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||
// currency) when this code has a snapshot; else keep the live amount.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
mod.surchargeCode,
|
||||
paymentCurrency,
|
||||
);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
? Math.round(unitUsd * usdToEtb)
|
||||
: unitUsd;
|
||||
const convertedAmount = frozen
|
||||
? isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity
|
||||
: isEtbBooking
|
||||
? Math.round(usdAmount * usdToEtb)
|
||||
: usdAmount;
|
||||
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeCode,
|
||||
description: surchargeLabel(mod.surchargeCode),
|
||||
@@ -424,6 +451,7 @@ export class BookingPricingService {
|
||||
private async computeBaseRailLinesWithRates(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const paymentCurrency = booking.paymentCurrency;
|
||||
@@ -453,15 +481,35 @@ export class BookingPricingService {
|
||||
if (!rate) continue;
|
||||
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(rate.rateValue);
|
||||
// H15: frozen contract rate for this container size, when present — its
|
||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||
const frozen = await this.frozenRateForContainer(
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
unitAmount,
|
||||
container.quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `${label} rail freight`,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -477,14 +525,31 @@ export class BookingPricingService {
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
||||
: null;
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = this.amountForUnit(
|
||||
fallback.rateUnit,
|
||||
unitAmount,
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
} else {
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
}
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: fallback.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
@@ -508,6 +573,7 @@ export class BookingPricingService {
|
||||
private async computeFirstLastMileLines(
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null = null,
|
||||
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
|
||||
const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
|
||||
{
|
||||
@@ -565,18 +631,34 @@ export class BookingPricingService {
|
||||
break;
|
||||
}
|
||||
|
||||
const usdAmount = value * quantity;
|
||||
// H15: frozen mile rate (already in booking currency) when the contract
|
||||
// has one; else the live USD rate converted as before.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
if (frozen) {
|
||||
unitAmount = Number(frozen.unitPrice);
|
||||
amount = isEtbBooking
|
||||
? Math.round(unitAmount * quantity)
|
||||
: unitAmount * quantity;
|
||||
} else {
|
||||
const usdAmount = value * quantity;
|
||||
amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
|
||||
}
|
||||
// Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
|
||||
if (!(usdAmount > 0)) continue;
|
||||
if (!(amount > 0)) continue;
|
||||
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = value;
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
lines.push({
|
||||
code: leg.rateType,
|
||||
description: leg.label,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unitAmount,
|
||||
unit: rate.rateUnit,
|
||||
quantity,
|
||||
currency: paymentCurrency,
|
||||
@@ -649,21 +731,93 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||||
const value = Number(rate.rateValue);
|
||||
switch (rate.rateUnit) {
|
||||
return this.amountForUnit(
|
||||
rate.rateUnit,
|
||||
Number(rate.rateValue),
|
||||
quantity,
|
||||
wagonCount,
|
||||
);
|
||||
}
|
||||
|
||||
/** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
|
||||
private amountForUnit(
|
||||
rateUnit: string,
|
||||
unitValue: number,
|
||||
quantity: number,
|
||||
wagonCount: number,
|
||||
): number {
|
||||
switch (rateUnit) {
|
||||
case 'PER_CONTAINER':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'PER_WAGON':
|
||||
return value * wagonCount;
|
||||
return unitValue * wagonCount;
|
||||
case 'PER_TON':
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
case 'FLAT':
|
||||
return value;
|
||||
return unitValue;
|
||||
default:
|
||||
return value * quantity;
|
||||
return unitValue * quantity;
|
||||
}
|
||||
}
|
||||
|
||||
// ── H15: frozen contract rate snapshots ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
|
||||
* for a non-contract booking (or a contract with no snapshots). The pricing
|
||||
* line builders prefer a matching snapshot's unit price over the live rate.
|
||||
*/
|
||||
private async loadFrozenContractRates(
|
||||
booking: Booking,
|
||||
): Promise<Map<string, ContractRateSnapshot> | null> {
|
||||
if (!booking.contractId) return null;
|
||||
const snapshots = await this.bookingsRepository.findContractRateSnapshots(
|
||||
booking.contractId,
|
||||
);
|
||||
if (!snapshots.length) return null;
|
||||
const byCode = new Map<string, ContractRateSnapshot>();
|
||||
for (const snap of snapshots) byCode.set(snap.rateCode, snap);
|
||||
return byCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
||||
* is negative, or it is in a different currency than the booking (in which
|
||||
* case the live-rate path is safer than a mis-converted frozen price).
|
||||
*/
|
||||
private frozenRateByCode(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
if (snap.currency !== bookingCurrency) return null;
|
||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
||||
return snap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen base-rail snapshot for a container line, matched by the
|
||||
* container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
|
||||
* ContractPricingService freezes). Null when there is no snapshot.
|
||||
*/
|
||||
private async frozenRateForContainer(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
try {
|
||||
sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!sizeFt) return null;
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||
}
|
||||
|
||||
private lineItemsSignature(items: PriceLineItemDto[]): string {
|
||||
return JSON.stringify(
|
||||
[...items]
|
||||
|
||||
@@ -534,6 +534,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a cancelled booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
@@ -562,6 +566,10 @@ export class BookingTransitionService {
|
||||
"REJECTION",
|
||||
);
|
||||
|
||||
// Stop the open-invoice leak: a rejected booking must not leave a payable
|
||||
// invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
|
||||
await this.invoiceService.expireOpenInvoices(bookingId);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: "REJECTED",
|
||||
} as never);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ContractRoute } from '../contracts/entities/contract-route.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -200,6 +201,19 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return Number(route?.km ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Frozen contract unit-rate snapshots for a contract (H15). A booking created
|
||||
* under a contract prices from these agreed, frozen rates rather than the live
|
||||
* rate of the day; the pricing service matches them by rate code.
|
||||
*/
|
||||
findContractRateSnapshots(
|
||||
contractId: string,
|
||||
): Promise<ContractRateSnapshot[]> {
|
||||
return this.dataSource
|
||||
.getRepository(ContractRateSnapshot)
|
||||
.find({ where: { contractId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
@@ -217,10 +231,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||
|
||||
const qb = this.repository
|
||||
const repo = manager ? manager.getRepository(Booking) : this.repository;
|
||||
const qb = repo
|
||||
.createQueryBuilder('b')
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
@@ -257,7 +273,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
);
|
||||
}
|
||||
|
||||
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||
qb.orderBy('b.createdAt', 'ASC');
|
||||
|
||||
// H9: under the caller's transaction, take a write lock on the matched
|
||||
// partner booking row (FOR UPDATE OF b — booking rows only, not the joined
|
||||
// reference tables) so a concurrent consolidation cannot claim the same
|
||||
// partner between this find and the pair write. Only when a transaction
|
||||
// manager is supplied — a pessimistic lock requires an open transaction.
|
||||
if (manager) {
|
||||
qb.setLock('pessimistic_write', undefined, ['b']);
|
||||
}
|
||||
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||
@@ -268,9 +295,14 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
}>,
|
||||
manager?: EntityManager,
|
||||
): Promise<Booking | null> {
|
||||
for (const slot of slots) {
|
||||
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
|
||||
const partner = await this.findComplementaryConsolidationPartner(
|
||||
booking,
|
||||
slot,
|
||||
manager,
|
||||
);
|
||||
if (partner) return partner;
|
||||
}
|
||||
return null;
|
||||
@@ -308,6 +340,63 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Race-safe pairing (H9): the transactional counterpart of
|
||||
* {@link pairConsolidation}. Must run inside the caller's transaction
|
||||
* (`manager`), which should already hold the partner-row write lock taken by
|
||||
* {@link findComplementaryConsolidationPartner}. Re-reads both rows and
|
||||
* re-asserts `consolidationPartnerId IS NULL` on each before writing; returns
|
||||
* `false` (no write) when either booking was already paired by a concurrent
|
||||
* flow, so the caller can fall back to parking.
|
||||
*/
|
||||
async pairConsolidationIfUnpaired(
|
||||
bookingId: string,
|
||||
partnerId: string,
|
||||
manager: EntityManager,
|
||||
): Promise<boolean> {
|
||||
const repo = manager.getRepository(Booking);
|
||||
// Sequential (one connection per transaction) — never Promise.all here.
|
||||
const booking = await repo.findOne({
|
||||
where: { id: bookingId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
const partner = await repo.findOne({
|
||||
where: { id: partnerId },
|
||||
select: {
|
||||
id: true,
|
||||
consolidationPartnerId: true,
|
||||
consolidationResumeStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-assert both are still unpaired before writing (the partner row is held
|
||||
// under the finder's write lock, so its state is stable here).
|
||||
if (
|
||||
!booking ||
|
||||
!partner ||
|
||||
booking.consolidationPartnerId != null ||
|
||||
partner.consolidationPartnerId != null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await repo.update(bookingId, {
|
||||
consolidationPartnerId: partnerId,
|
||||
status: booking.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
await repo.update(partnerId, {
|
||||
consolidationPartnerId: bookingId,
|
||||
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
} as never);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Park a booking that needs consolidation but has no partner yet. The optional
|
||||
* resumeStatus is where the booking returns once it pairs — pass it for a
|
||||
|
||||
@@ -508,13 +508,28 @@ export class BookingsService {
|
||||
return { booking, messages };
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
);
|
||||
// H9: find + pair must be atomic. Run both inside one transaction where the
|
||||
// finder holds a write lock on the candidate partner row and pairing
|
||||
// re-asserts both rows are still unpaired before writing — otherwise two
|
||||
// concurrent bookings can claim the same partner (or pair an
|
||||
// already-paired booking). `didPair` is false when a concurrent flow won
|
||||
// the partner, in which case we fall through to parking below.
|
||||
const partner = await this.dataSource.transaction(async (manager) => {
|
||||
const candidate = await this.bookingsRepository.findConsolidationPartner(
|
||||
booking,
|
||||
slots,
|
||||
manager,
|
||||
);
|
||||
if (!candidate) return null;
|
||||
const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired(
|
||||
booking.id,
|
||||
candidate.id,
|
||||
manager,
|
||||
);
|
||||
return didPair ? candidate : null;
|
||||
});
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
const paired = await this.findById(booking.id);
|
||||
messages.push(
|
||||
this.consolidationService.describePaired(partner.reference, slots),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Not, Repository } from 'typeorm';
|
||||
import { CreateCargoDto } from './dto/create-cargo.dto';
|
||||
import { UpdateCargoDto } from './dto/update-cargo.dto';
|
||||
import { LoadCargoDto } from './dto/load-cargo.dto';
|
||||
@@ -32,6 +32,7 @@ export class CargoesService {
|
||||
if (!container) {
|
||||
throw new NotFoundException(`Container ${dto.containerId} not found`);
|
||||
}
|
||||
await this.assertContainerCapacity(container, dto.weight);
|
||||
|
||||
if (dto.cargoTypeId) {
|
||||
const cargoType = await this.cargoTypeRepo.findOne({
|
||||
@@ -121,6 +122,10 @@ export class CargoesService {
|
||||
throw new ConflictException('Cargo already loaded or delivered');
|
||||
}
|
||||
|
||||
if (cargo.container) {
|
||||
await this.assertContainerCapacity(cargo.container, dto.weight, cargo.id);
|
||||
}
|
||||
|
||||
cargo.status = 'LOADED';
|
||||
cargo.loadedAt = new Date();
|
||||
cargo.quantity = dto.quantity;
|
||||
@@ -137,13 +142,31 @@ export class CargoesService {
|
||||
}
|
||||
|
||||
async unloadCargo(id: string): Promise<Cargo> {
|
||||
const cargo = await this.findById(id);
|
||||
const cargo = await this.cargoRepo.findOne({
|
||||
where: { id },
|
||||
relations: { container: true },
|
||||
});
|
||||
if (!cargo) throw new NotFoundException('Cargo not found');
|
||||
if (cargo.status !== 'LOADED') {
|
||||
throw new ConflictException('Cargo is not loaded');
|
||||
}
|
||||
cargo.status = 'UNLOADED';
|
||||
cargo.unloadedAt = new Date();
|
||||
return this.cargoRepo.save(cargo);
|
||||
const saved = await this.cargoRepo.save(cargo);
|
||||
|
||||
// loadCargo flips the container to LOADED; on unload, free it back to
|
||||
// AVAILABLE once no other LOADED cargo still references the container.
|
||||
if (cargo.containerId != null && cargo.container) {
|
||||
const remaining = await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
cargo.container.status = 'AVAILABLE';
|
||||
await this.containerRepo.save(cargo.container);
|
||||
}
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise<Cargo> {
|
||||
@@ -161,10 +184,13 @@ export class CargoesService {
|
||||
if (dto?.receiverName) cargo.receiverName = dto.receiverName;
|
||||
if (dto?.deliveryRemarks) cargo.deliveryRemarks = dto.deliveryRemarks;
|
||||
|
||||
// Exclude the cargo being delivered — it is still LOADED in the DB until the
|
||||
// save below, so counting it would keep `remaining` > 0 and never free the
|
||||
// container.
|
||||
const remaining =
|
||||
cargo.containerId != null
|
||||
? await this.cargoRepo.count({
|
||||
where: { containerId: cargo.containerId, status: 'LOADED' },
|
||||
where: { containerId: cargo.containerId, status: 'LOADED', id: Not(cargo.id) },
|
||||
})
|
||||
: 0;
|
||||
if (remaining === 0 && cargo.container) {
|
||||
@@ -174,4 +200,34 @@ export class CargoesService {
|
||||
|
||||
return this.cargoRepo.save(cargo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject when placing `newWeightKg` on the container would exceed its max gross
|
||||
* weight. All values are kilograms: cargoes.weight is kg (entity), and the
|
||||
* container's tare_weight / max_gross_weight are kg (entity). Capacity check is
|
||||
* tare + already-LOADED cargo + new cargo <= max gross weight.
|
||||
*/
|
||||
private async assertContainerCapacity(
|
||||
container: Container,
|
||||
newWeightKg: number,
|
||||
excludeCargoId?: string,
|
||||
): Promise<void> {
|
||||
const qb = this.cargoRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('COALESCE(SUM(c.weight), 0)', 'sum')
|
||||
.where('c.containerId = :containerId', { containerId: container.id })
|
||||
.andWhere('c.status = :status', { status: 'LOADED' });
|
||||
if (excludeCargoId) qb.andWhere('c.id != :excludeCargoId', { excludeCargoId });
|
||||
const raw = await qb.getRawOne<{ sum: string }>();
|
||||
|
||||
const loadedKg = Number(raw?.sum ?? 0);
|
||||
const tareKg = Number(container.tareWeight);
|
||||
const maxGrossKg = Number(container.maxGrossWeight);
|
||||
if (tareKg + loadedKg + newWeightKg > maxGrossKg) {
|
||||
throw new BadRequestException(
|
||||
`Cargo weight exceeds container capacity: tare ${tareKg}kg + loaded ${loadedKg}kg + ` +
|
||||
`new ${newWeightKg}kg > max gross ${maxGrossKg}kg`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateContainerDto } from './dto/create-container.dto';
|
||||
import { UpdateContainerDto } from './dto/update-container.dto';
|
||||
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
|
||||
@@ -18,6 +18,7 @@ export class ContainersService {
|
||||
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
|
||||
@InjectRepository(ContainerType)
|
||||
private readonly containerTypeRepo: Repository<ContainerType>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateContainerDto): Promise<Container> {
|
||||
@@ -115,24 +116,42 @@ export class ContainersService {
|
||||
if (container.status === 'LOADED') {
|
||||
throw new ConflictException('Cannot reassign a loaded container');
|
||||
}
|
||||
// Reject a container that is already placed on a wagon — it must be
|
||||
// unassigned first, otherwise it would silently jump to another wagon.
|
||||
if (container.wagonId) {
|
||||
throw new ConflictException(
|
||||
`Container ${containerId} is already assigned to wagon ${container.wagonId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
|
||||
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
|
||||
|
||||
let position: number | null = dto.position ?? null;
|
||||
if (position === null) {
|
||||
const maxPos = await this.containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
// The MAX(position)+1 allocation is check-then-act: two concurrent assigns can
|
||||
// read the same MAX and collide on the same position. Do the read + save inside
|
||||
// one transaction to narrow the race window.
|
||||
// TODO: add a unique (wagon_id, position) DB index so the database itself
|
||||
// rejects a colliding position even under concurrency.
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const containerRepo = manager.getRepository(Container);
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position;
|
||||
container.status = 'AVAILABLE';
|
||||
return this.containerRepo.save(container);
|
||||
let position: number | null = dto.position ?? null;
|
||||
if (position === null) {
|
||||
const maxPos = await containerRepo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.position)', 'max')
|
||||
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
|
||||
.getRawOne<{ max: number | null }>();
|
||||
position = (maxPos?.max ?? 0) + 1;
|
||||
}
|
||||
|
||||
container.wagonId = wagon.id;
|
||||
container.position = position;
|
||||
// Placing a container on a wagon does not make it AVAILABLE. The status enum
|
||||
// (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON
|
||||
// state, so leave the existing status unchanged rather than forcing AVAILABLE.
|
||||
return containerRepo.save(container);
|
||||
});
|
||||
}
|
||||
|
||||
async unassignFromWagon(containerId: string): Promise<Container> {
|
||||
|
||||
@@ -78,12 +78,21 @@ export class ClearanceFeeService {
|
||||
* the pre-fee flow instead of dead-ending.
|
||||
*/
|
||||
async gateApplies(contract: Contract): Promise<boolean> {
|
||||
if (!contract.customsClearingEnabled || !contract.companyId) return false;
|
||||
if ((await this.feeAmountOrNull(contract)) !== null) return true;
|
||||
this.logger.warn(
|
||||
`Contract ${contract.reference} has customs enabled but no frozen clearance fee — skipping the prepay gate (legacy contract).`,
|
||||
);
|
||||
return false;
|
||||
// Customs disabled → the prepay gate genuinely does not apply.
|
||||
if (!contract.customsClearingEnabled) return false;
|
||||
// No company to bill (government / unlinked) → the gate cannot raise an
|
||||
// invoice, so it stays out of the flow (same rule the booking invoice uses).
|
||||
if (!contract.companyId) return false;
|
||||
// M26: customs IS enabled and billable. A missing frozen fee line must NOT
|
||||
// silently waive the gate — that ships clearance for free. Hard-fail exactly
|
||||
// as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a
|
||||
// missing fee blocks counter-sign / shipment instead of bypassing payment.
|
||||
if ((await this.feeAmountOrNull(contract)) === null) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Issue (idempotently) the ONE_TIME contract-level fee invoice. */
|
||||
|
||||
@@ -793,17 +793,34 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
|
||||
if (dto.role === 'CUSTOMER') {
|
||||
// H12(a): only the owning company's customer may sign — assert ownership
|
||||
// before anything else (hidden as NotFound otherwise). A signing customer
|
||||
// has no permission key, so this is the gate that binds the sign to the
|
||||
// contract's company.
|
||||
await this.contractsService.assertCustomerCanAccessContract(
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||
if (existing) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
|
||||
// must be verified before the signature is applied.
|
||||
if (!dto.otpPhone || !dto.otp) {
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the CONTRACT COMPANY's
|
||||
// registered phone — never the caller-supplied dto.otpPhone, which an
|
||||
// attacker could point at their own phone to sign someone else's
|
||||
// contract. The OTP is issued to the company's registered number.
|
||||
const companyPhone = contract.company?.phone?.trim();
|
||||
if (!companyPhone) {
|
||||
throw new BadRequestException(
|
||||
'The contract company has no registered phone on file to verify the signing OTP against',
|
||||
);
|
||||
}
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: dto.otpPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction({ phone: companyPhone }, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -524,12 +524,18 @@ export class ContractsController {
|
||||
|
||||
@Post(':id/renew')
|
||||
@ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' })
|
||||
renew(
|
||||
async renew(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() _dto: RenewContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.renew(id, user?.id ?? user?.sub);
|
||||
// H12(c): a customer may only renew a contract their company owns. Staff
|
||||
// with bookings.view bypass, mirroring getContractView/downloadContractDocument.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.transitionService.renew(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
// ── Pre-booking clearance (Path B, doc §15.2.1) ────────────────────────────
|
||||
@@ -544,10 +550,17 @@ export class ContractsController {
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' })
|
||||
uploadClearanceDocuments(
|
||||
async uploadClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
// H12(c): only the owning company's customer may upload clearance docs.
|
||||
// Staff with bookings.view bypass, mirroring the other contract handlers.
|
||||
const contract = await this.contractsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||
}
|
||||
return this.clearanceService.uploadDocuments(id, files ?? []);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const;
|
||||
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
||||
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
||||
// against 'WITH_RETURN'/'WITHOUT_RETURN'. Lowercase input is normalized.
|
||||
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
export {
|
||||
CONTRACT_KINDS,
|
||||
@@ -161,6 +164,9 @@ export class CreateContractDto {
|
||||
|
||||
@ApiPropertyOptional({ enum: EQUIPMENT_RETURNS })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.toUpperCase() : value,
|
||||
)
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn?: string;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
@@ -72,7 +73,30 @@ export class DriversController {
|
||||
@Post(':id/documents')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
// Bound the upload: 10MB/file, max 20 files, images + PDF only. Without limits
|
||||
// AnyFilesInterceptor buffers arbitrarily large / arbitrary-type payloads.
|
||||
@UseInterceptors(
|
||||
AnyFilesInterceptor({
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 20 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'application/pdf',
|
||||
];
|
||||
if (allowed.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(
|
||||
new BadRequestException(`Unsupported file type: ${file.mimetype}`),
|
||||
false,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiOperation({ summary: 'Upload driver documents (code driver_docs)' })
|
||||
uploadDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -6,22 +6,24 @@ import {
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { Public } from "@edr/api-common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import { FilesService } from "./files.service";
|
||||
|
||||
@ApiTags("files")
|
||||
@ApiBearerAuth()
|
||||
@Controller("files")
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
@Get(":fileId")
|
||||
// Public so the browser can load the bytes directly via <img>/<iframe>/<a> —
|
||||
// those requests can't carry the Bearer token the axios client injects, so a
|
||||
// guarded route 401s. File UUIDs are unguessable; same tradeoff as webhooks.
|
||||
@Public()
|
||||
// Authenticated: no @Public, so the global JwtGuard applies. Unguessable file
|
||||
// UUIDs are obscurity, not authorization — raw byte streams must require auth.
|
||||
// Browser inline previews (<img>/<iframe>/<a>) that can't carry the Bearer
|
||||
// token should use a short-lived signed URL instead (FilesService.signUrl).
|
||||
// TODO: enforce ownership-by-resource here next (scope the file to the
|
||||
// caller's booking/company before streaming).
|
||||
@ApiOperation({
|
||||
summary: "Stream a file by ID",
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
@@ -28,6 +32,31 @@ function sanitizeObjectName(name: string): string {
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
// Defense-in-depth for ANY caller of upload() (not just the driver-docs
|
||||
// route). This is deliberately BROADER than the driver controller's strict
|
||||
// images+pdf Multer filter, because the same method also stores generated
|
||||
// PDFs, PNG signatures, and customer/customs booking documents (scans, office
|
||||
// docs). It rejects the actual attack surface (executables/scripts/HTML) while
|
||||
// permitting every business-document type these flows legitimately upload.
|
||||
// No file-upload-settings row governs raw byte size, so the cap is a sane,
|
||||
// generous default that won't reject large scanned documents.
|
||||
private static readonly MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
private static readonly ALLOWED_UPLOAD_MIME = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/tiff",
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/csv",
|
||||
"text/plain",
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private readonly filesRepository: FilesRepository,
|
||||
private readonly minioService: MinioService,
|
||||
@@ -35,6 +64,15 @@ export class FilesService {
|
||||
|
||||
async upload(input: CreateFileInput): Promise<FileRecord> {
|
||||
const { resourceId, resource, code, file } = input;
|
||||
|
||||
if (!FilesService.ALLOWED_UPLOAD_MIME.has(file.mimetype)) {
|
||||
throw new BadRequestException(`Unsupported file type: ${file.mimetype}`);
|
||||
}
|
||||
if (file.size > FilesService.MAX_UPLOAD_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`File exceeds the ${FilesService.MAX_UPLOAD_BYTES / (1024 * 1024)}MB upload limit`,
|
||||
);
|
||||
}
|
||||
// Keep the object key URL-safe so it survives the round-trip through the
|
||||
// stored URL (spaces/unicode in the original name would otherwise be
|
||||
// percent-encoded in the URL and no longer match the MinIO key). The
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { IsUUID, IsNumber, IsPositive, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { PaymentMethod } from '../entities/fuel-purchase.entity';
|
||||
|
||||
export class CreateFuelPurchaseDto {
|
||||
@@ -9,9 +9,11 @@ export class CreateFuelPurchaseDto {
|
||||
purchaseDate!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
liters!: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
costPerLiter!: number;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { FuelRepository } from './fuel.repository';
|
||||
@@ -15,6 +15,18 @@ export class FuelService {
|
||||
) {}
|
||||
|
||||
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
|
||||
// Reject a re-submitted receipt for the same vehicle (double-entry guard).
|
||||
if (dto.receiptNumber) {
|
||||
const duplicate = await this.purchaseRepository.findOne({
|
||||
where: { vehicleId: dto.vehicleId, receiptNumber: dto.receiptNumber },
|
||||
});
|
||||
if (duplicate) {
|
||||
throw new ConflictException(
|
||||
`A fuel purchase with receipt number ${dto.receiptNumber} already exists for this vehicle`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCost = dto.liters * dto.costPerLiter;
|
||||
|
||||
const purchase = this.purchaseRepository.create({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
@@ -15,6 +17,9 @@ import { ImportOperationsService } from './import-operations.service';
|
||||
@ApiTags('import-operations')
|
||||
@ApiBearerAuth()
|
||||
@Controller('import-operations')
|
||||
// Post-booking customs / import-operations actions are GL/Ops work, mirroring the
|
||||
// contracts controller's GL operational endpoints (risk, duty, milestones).
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
export class ImportOperationsController {
|
||||
constructor(private readonly service: ImportOperationsService) {}
|
||||
|
||||
|
||||
@@ -8,18 +8,26 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { IncidentsService } from './incidents.service';
|
||||
import { CreateIncidentDto } from './dto/create-incident.dto';
|
||||
import { UpdateIncidentDto } from './dto/update-incident.dto';
|
||||
import { IncidentStatus, IncidentType } from './entities/incident.entity';
|
||||
|
||||
@ApiTags('Accident & Incident Management')
|
||||
@ApiBearerAuth()
|
||||
@Controller('incidents')
|
||||
// No incidents-specific permission exists in the registry, so this reuses the
|
||||
// (real) drivers.* fleet-road keys — incident records are driver-safety data
|
||||
// (driver stats / incident history). TODO: add a dedicated incidents:* key.
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.view)
|
||||
export class IncidentsController {
|
||||
constructor(private readonly incidentsService: IncidentsService) {}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.create)
|
||||
@ApiOperation({ summary: 'Report an incident' })
|
||||
async create(@Body() dto: CreateIncidentDto) {
|
||||
return this.incidentsService.create(dto);
|
||||
@@ -55,12 +63,14 @@ export class IncidentsController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.update)
|
||||
@ApiOperation({ summary: 'Update an incident' })
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateIncidentDto) {
|
||||
return this.incidentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.drivers.delete)
|
||||
@ApiOperation({ summary: 'Delete an incident' })
|
||||
async remove(@Param('id') id: string) {
|
||||
await this.incidentsService.remove(id);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { GenerateFromScheduleDto } from './dto/generate-from-schedule.dto';
|
||||
import { InterchangeDocumentQueryDto } from './dto/interchange-document-query.dto';
|
||||
import {
|
||||
@@ -12,6 +14,8 @@ import { InterchangeDocumentsService } from './interchange-documents.service';
|
||||
@ApiTags('interchange-documents')
|
||||
@ApiBearerAuth()
|
||||
@Controller('interchange-documents')
|
||||
// Class-level view guard; each write route adds its own manage permission below.
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.view)
|
||||
export class InterchangeDocumentsController {
|
||||
constructor(private readonly service: InterchangeDocumentsService) {}
|
||||
|
||||
@@ -28,12 +32,14 @@ export class InterchangeDocumentsController {
|
||||
}
|
||||
|
||||
@Post('generate-from-schedule')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.generate)
|
||||
@ApiOperation({ summary: 'Generate interchange document from a train schedule handover' })
|
||||
generateFromSchedule(@Body() dto: GenerateFromScheduleDto) {
|
||||
return this.service.generateFromSchedule(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/acknowledge')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.acknowledge)
|
||||
@ApiOperation({ summary: 'Acknowledge an interchange document' })
|
||||
acknowledge(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@@ -43,12 +49,14 @@ export class InterchangeDocumentsController {
|
||||
}
|
||||
|
||||
@Patch(':id/dispute')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.dispute)
|
||||
@ApiOperation({ summary: 'Dispute an interchange document' })
|
||||
dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) {
|
||||
return this.service.dispute(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/cancel')
|
||||
@BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel)
|
||||
@ApiOperation({ summary: 'Cancel a draft/generated interchange document' })
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancel(id);
|
||||
|
||||
@@ -184,8 +184,13 @@ export class InterchangeDocumentsService {
|
||||
dto: AcknowledgeInterchangeDocumentDto,
|
||||
): Promise<InterchangeDocument> {
|
||||
const document = await this.findOne(id);
|
||||
if (document.status === 'CANCELLED') {
|
||||
throw new BadRequestException('Cancelled interchange document cannot be acknowledged');
|
||||
// Only a freshly GENERATED document can be acknowledged. Rejecting DISPUTED
|
||||
// (as well as CANCELLED / already-ACKNOWLEDGED) stops an acknowledge from
|
||||
// silently overriding a raised dispute.
|
||||
if (document.status !== 'GENERATED') {
|
||||
throw new BadRequestException(
|
||||
`Interchange document in ${document.status} status cannot be acknowledged (must be GENERATED)`,
|
||||
);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||
status: 'ACKNOWLEDGED',
|
||||
@@ -197,7 +202,14 @@ export class InterchangeDocumentsService {
|
||||
}
|
||||
|
||||
async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise<InterchangeDocument> {
|
||||
await this.findOne(id);
|
||||
const document = await this.findOne(id);
|
||||
// A dispute can only be raised on a live handover — a GENERATED or already
|
||||
// ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here.
|
||||
if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) {
|
||||
throw new BadRequestException(
|
||||
`Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`,
|
||||
);
|
||||
}
|
||||
await this.dataSource.getRepository(InterchangeDocument).update(id, {
|
||||
status: 'DISPUTED',
|
||||
remarks: dto.remarks,
|
||||
@@ -273,7 +285,10 @@ export class InterchangeDocumentsService {
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
-- Item weight is normalized to TONS. total_vgm_tons and
|
||||
-- cargo_total_weight_vgm are already tons; containers.max_gross_weight
|
||||
-- is kilograms, so convert it (kg -> tons).
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
|
||||
COALESCE(bc.quantity, 1) AS "quantity",
|
||||
COALESCE(bc.quantity, 1) AS "packageCount",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
@@ -322,7 +337,9 @@ export class InterchangeDocumentsService {
|
||||
cg.id AS "cargoId",
|
||||
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
|
||||
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
|
||||
COALESCE(cg.weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
-- Normalized to TONS: cargoes.weight is kilograms (convert), while
|
||||
-- cargo_total_weight_vgm is already tons.
|
||||
COALESCE(cg.weight / 1000.0 /* kg->tons */, a.cargo_total_weight_vgm) AS "weight",
|
||||
cg.quantity AS "quantity",
|
||||
cg.quantity AS "packageCount",
|
||||
a.wagon_number AS "wagonNumber",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
@@ -9,11 +10,23 @@ import {
|
||||
type LocomotiveStatus,
|
||||
type LocomotiveType,
|
||||
} from './entities/locomotive.entity';
|
||||
import { TrainLocomotive } from '../trains/entities/train-locomotive.entity';
|
||||
import { LocomotivesRepository } from './locomotives.repository';
|
||||
|
||||
@Injectable()
|
||||
export class LocomotivesService {
|
||||
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
|
||||
constructor(
|
||||
private readonly locomotivesRepository: LocomotivesRepository,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** The built-train link (if any) coupling this locomotive to a fleet train. */
|
||||
private findTrainLink(locomotiveId: string): Promise<TrainLocomotive | null> {
|
||||
return this.dataSource.getRepository(TrainLocomotive).findOne({
|
||||
where: { locomotiveId },
|
||||
relations: { train: true },
|
||||
});
|
||||
}
|
||||
|
||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||
return this.locomotivesRepository.findAll({
|
||||
@@ -94,6 +107,22 @@ export class LocomotivesService {
|
||||
}
|
||||
}
|
||||
|
||||
// A locomotive coupled to a built train follows the train: its yard and
|
||||
// status are owned by the train-builder flow, not this generic PATCH.
|
||||
const link = await this.findTrainLink(id);
|
||||
if (link) {
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== link.train?.currentYardId) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; move the train (train-builder yard change) instead`,
|
||||
);
|
||||
}
|
||||
if (dto.status !== undefined && dto.status !== locomotive.status) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before changing its status`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.locomotivesRepository.update(id, {
|
||||
...dto,
|
||||
locomotiveType:
|
||||
@@ -119,7 +148,16 @@ export class LocomotivesService {
|
||||
}
|
||||
|
||||
async decommission(id: string): Promise<Locomotive> {
|
||||
await this.findById(id);
|
||||
const locomotive = await this.findById(id);
|
||||
|
||||
// Can't retire a locomotive that is still coupled to a built train — detach
|
||||
// it in the train-builder first so the train never loses a live loco.
|
||||
const link = await this.findTrainLink(id);
|
||||
if (link) {
|
||||
throw new ConflictException(
|
||||
`Locomotive ${locomotive.code} is coupled to train ${link.train?.code}; detach it before taking it out of service`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await this.locomotivesRepository.update(id, {
|
||||
status: 'OUT_OF_SERVICE',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { MaintenanceRepository } from './maintenance.repository';
|
||||
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,15 +15,41 @@ export class MaintenanceService {
|
||||
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
|
||||
@InjectRepository(MaintenanceCost)
|
||||
private readonly costRepository: Repository<MaintenanceCost>,
|
||||
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
||||
// reach it through the global DataSource rather than @InjectRepository.
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
||||
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
||||
* maintenance completes or is cancelled it returns to service (ACTIVE + FREE).
|
||||
* Only the vehicle's status/availability columns are written here. The
|
||||
* assignment-side reject (first-mile/last-mile refusing MAINTENANCE vehicles)
|
||||
* lives in those excluded mile modules, not here.
|
||||
*/
|
||||
private async setVehicleMaintenanceState(
|
||||
vehicleId: string,
|
||||
underMaintenance: boolean,
|
||||
): Promise<void> {
|
||||
await this.dataSource.getRepository(Vehicle).update(vehicleId, {
|
||||
status: underMaintenance ? VehicleStatus.MAINTENANCE : VehicleStatus.ACTIVE,
|
||||
availability: underMaintenance
|
||||
? VehicleAvailability.BUSY
|
||||
: VehicleAvailability.FREE,
|
||||
});
|
||||
}
|
||||
|
||||
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
|
||||
const schedule = this.scheduleRepository.create({
|
||||
...dto,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
|
||||
});
|
||||
return this.scheduleRepository.save(schedule);
|
||||
const saved = await this.scheduleRepository.save(schedule);
|
||||
// Scheduling maintenance takes the vehicle out of the available pool.
|
||||
await this.setVehicleMaintenanceState(saved.vehicleId, true);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
|
||||
@@ -42,6 +69,21 @@ export class MaintenanceService {
|
||||
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
|
||||
});
|
||||
const updated = await this.scheduleRepository.findOneBy({ id });
|
||||
|
||||
// Keep the vehicle's status/availability in step with the schedule status.
|
||||
if (updated && dto.status) {
|
||||
if (
|
||||
dto.status === MaintenanceStatus.COMPLETED ||
|
||||
dto.status === MaintenanceStatus.CANCELLED
|
||||
) {
|
||||
// Maintenance finished/aborted → vehicle back in service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, false);
|
||||
} else if (dto.status === MaintenanceStatus.IN_PROGRESS) {
|
||||
// Maintenance started → keep the vehicle out of service.
|
||||
await this.setVehicleMaintenanceState(updated.vehicleId, true);
|
||||
}
|
||||
}
|
||||
|
||||
return updated!;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||
import { NotificationStrategy } from "./strategies/notification.strategy";
|
||||
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||
@@ -27,8 +32,19 @@ export class NotificationsService {
|
||||
if (!strategy) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
// A strategy returning false (or throwing) is a real delivery failure — do
|
||||
// not swallow it. Surface it so callers observe the failure (existing
|
||||
// callers wrap directSend in try/catch for best-effort notifications).
|
||||
const sent = await strategy.send(recipient, message);
|
||||
this.logger.log(`is sent - ${sent}`);
|
||||
if (!sent) {
|
||||
this.logger.error(
|
||||
`Notification via ${method} to ${recipient} failed to send`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
`Failed to send ${method} notification`,
|
||||
);
|
||||
}
|
||||
this.logger.log(`Notification via ${method} to ${recipient} sent`);
|
||||
}
|
||||
|
||||
async notifyDriverVehicleAssignment(params: {
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { NotificationStrategy } from "./notification.strategy";
|
||||
import { EmailClientService } from "../email-client.service";
|
||||
|
||||
@Injectable()
|
||||
export class EmailNotificationStrategy implements NotificationStrategy {
|
||||
private readonly logger = new Logger(EmailNotificationStrategy.name);
|
||||
constructor() { }
|
||||
constructor(private readonly emailClient: EmailClientService) { }
|
||||
async send(recipient: string, message: string): Promise<boolean> {
|
||||
this.logger.log(`${recipient}, ${message}`)
|
||||
return false;
|
||||
try {
|
||||
// Route through the shared email client (RabbitMQ hand-off). `queued`
|
||||
// reflects whether the message was accepted for delivery; a false or
|
||||
// a thrown result is a real failure the caller must observe.
|
||||
const { queued } = await this.emailClient.sendEmail({
|
||||
to: recipient,
|
||||
subject: "EDR Freight notification",
|
||||
text: message,
|
||||
});
|
||||
return queued;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to send email to ${recipient}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err instanceof Error ? err.stack : undefined,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ function toTarget(phone?: string, email?: string): OtpTarget {
|
||||
throw new BadRequestException("phone or email is required");
|
||||
}
|
||||
|
||||
// TODO: these public routes need per-target + per-IP rate limiting (a NestJS
|
||||
// ThrottlerGuard / @Throttle on /otp/send and /otp/verify). No Throttler is
|
||||
// wired into the app yet; add @nestjs/throttler and apply it here.
|
||||
@Controller("otp")
|
||||
@Public()
|
||||
export class OtpController {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// otp.service.ts
|
||||
|
||||
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
|
||||
import { randomInt } from "node:crypto";
|
||||
|
||||
import { OtpRepository } from "./otp.repository";
|
||||
|
||||
@@ -25,7 +26,9 @@ export class OtpService {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generateOtp(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
// Cryptographically secure 6-digit code (100000–999999). Math.random() is a
|
||||
// non-CSPRNG and must never be used to mint a security token.
|
||||
return randomInt(100000, 1000000).toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -50,8 +53,13 @@ export class OtpService {
|
||||
await this.otpRepository.createOtp(target, otp);
|
||||
}
|
||||
|
||||
// A freshly issued code gets a fresh guess budget.
|
||||
this.actionAttempts.delete(this.targetKey(target));
|
||||
// NOTE: do NOT reset the brute-force attempt counter on send. Clearing it
|
||||
// here let an attacker wipe the per-target guess budget just by calling
|
||||
// /otp/send between guesses. The counter is cleared only when the code is
|
||||
// consumed/expired during verification.
|
||||
// TODO: add per-target + per-IP rate limiting on the public /otp/send and
|
||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||
// in the codebase yet.
|
||||
|
||||
if (target.email) {
|
||||
// send email (queued to RabbitMQ via the shared Email service)
|
||||
@@ -94,6 +102,7 @@ export class OtpService {
|
||||
async verifyOtp(target: OtpTarget, otp: string) {
|
||||
// find the channel's row
|
||||
const otpData = await this.otpRepository.findByTarget(target);
|
||||
const key = this.targetKey(target);
|
||||
|
||||
// not found
|
||||
if (!otpData) {
|
||||
@@ -102,13 +111,35 @@ export class OtpService {
|
||||
);
|
||||
}
|
||||
|
||||
// invalid otp
|
||||
// TTL: reuse the same age window as the hardened action verifier — an old
|
||||
// code can't be verified.
|
||||
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
|
||||
if (ageMs > this.ACTION_OTP_TTL_MS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
throw new BadRequestException(
|
||||
"Verification code has expired. Request a new one.",
|
||||
);
|
||||
}
|
||||
|
||||
// invalid otp — per-target attempt cap so a 6-digit code can't be
|
||||
// brute-forced within its TTL; the code is burned once the budget is spent.
|
||||
if (otpData.otp !== otp) {
|
||||
const attempts = (this.actionAttempts.get(key) ?? 0) + 1;
|
||||
if (attempts >= this.MAX_ACTION_ATTEMPTS) {
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
throw new BadRequestException(
|
||||
"Too many incorrect attempts. Request a new code.",
|
||||
);
|
||||
}
|
||||
this.actionAttempts.set(key, attempts);
|
||||
throw new BadRequestException("Invalid OTP");
|
||||
}
|
||||
|
||||
// mark verified
|
||||
await this.otpRepository.markVerified(otpData);
|
||||
// single-use: consume the code on success so it can't be replayed.
|
||||
await this.otpRepository.deleteOtp(otpData);
|
||||
this.actionAttempts.delete(key);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { TrainScheduleStatus } from '@edr/types';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
@@ -112,6 +119,30 @@ export class RoutesService {
|
||||
? await this.validateMilestones(dto.milestones)
|
||||
: null;
|
||||
|
||||
// Milestones or endpoints are about to be rewritten — reject if any
|
||||
// non-terminal schedule still references this route, otherwise its stop list
|
||||
// and distances would silently shift under a live plan. Status-only /
|
||||
// label-only edits (no milestones supplied) are always allowed.
|
||||
if (milestoneInput) {
|
||||
const activeSchedules = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.count({
|
||||
where: {
|
||||
routeId: id,
|
||||
status: In([
|
||||
TrainScheduleStatus.Draft,
|
||||
TrainScheduleStatus.Scheduled,
|
||||
TrainScheduleStatus.Dispatched,
|
||||
]),
|
||||
},
|
||||
});
|
||||
if (activeSchedules > 0) {
|
||||
throw new ConflictException(
|
||||
'This route is used by active train schedules and its stops cannot be changed. Create a new route instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Route).update(id, {
|
||||
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -82,6 +83,15 @@ export class PriorityRuleChangeRequestsService {
|
||||
): Promise<PriorityRuleChangeRequest> {
|
||||
const request = await this.findPending(id);
|
||||
|
||||
// Separation of duties: the requester cannot approve their own change.
|
||||
// TODO: split approval into a distinct approver permission rather than
|
||||
// relying on this id check.
|
||||
if (userId && userId === request.requestedByUserId) {
|
||||
throw new ForbiddenException(
|
||||
'You cannot approve a change request you submitted',
|
||||
);
|
||||
}
|
||||
|
||||
// Apply the change through the normal service so currency + range-collision
|
||||
// validation runs against the CURRENT rules; a stale request that now
|
||||
// collides fails here and stays PENDING for the approver to see the error.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -199,6 +200,12 @@ export class RatesService {
|
||||
if (rate.status !== 'PENDING_APPROVAL') {
|
||||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||||
}
|
||||
// Separation of duties: the proposer cannot approve their own rate.
|
||||
// TODO: split approval into a distinct CEO/approver permission — a proposer
|
||||
// who also holds the approve permission is still the wrong person to sign off.
|
||||
if (approverUserId === rate.proposedByStaffId) {
|
||||
throw new ForbiddenException('You cannot approve a rate you proposed');
|
||||
}
|
||||
const updated = await this.repository.update(id, {
|
||||
status: 'LIVE',
|
||||
approvedByCeoId: approverUserId,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString } from 'class-validator';
|
||||
|
||||
import { PreviewRescheduleDto } from './preview-reschedule.dto';
|
||||
|
||||
/**
|
||||
* Body for the maintenance-reschedule endpoint. This must be a real class (not
|
||||
* the previous `PreviewRescheduleDto & { newDepartureDate: string }`
|
||||
* intersection): an intersection type carries no class-validator metadata, so
|
||||
* Nest's ValidationPipe silently skipped validation of the whole payload.
|
||||
*/
|
||||
export class MaintenanceRescheduleDto extends PreviewRescheduleDto {
|
||||
@ApiProperty({ example: '2026-06-22T08:00:00.000Z' })
|
||||
@IsDateString()
|
||||
newDepartureDate!: string;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { SchedulingRescheduleService } from './scheduling-reschedule.service';
|
||||
|
||||
@ApiTags('train-scheduling')
|
||||
@@ -53,7 +54,7 @@ export class SchedulingMaintenanceController {
|
||||
@ApiOperation({ summary: 'Reschedule train for maintenance (new departure + rebalance)' })
|
||||
maintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||
@Body() dto: MaintenanceRescheduleDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.schedulingRescheduleService.maintenanceReschedule(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { SchedulingEvent, type RescheduleTrigger } from './entities/scheduling-event.entity';
|
||||
|
||||
@@ -12,14 +12,18 @@ export class SchedulingRescheduleRepository {
|
||||
) {}
|
||||
|
||||
/** Persist an audit record for a completed reschedule. */
|
||||
async createEvent(data: {
|
||||
trainScheduleId: string;
|
||||
trigger: RescheduleTrigger;
|
||||
actorUserId?: string;
|
||||
reason?: string;
|
||||
planSnapshot: Record<string, unknown>;
|
||||
displacedBookingIds: string[];
|
||||
}): Promise<SchedulingEvent> {
|
||||
return this.repository.save(this.repository.create(data));
|
||||
async createEvent(
|
||||
data: {
|
||||
trainScheduleId: string;
|
||||
trigger: RescheduleTrigger;
|
||||
actorUserId?: string;
|
||||
reason?: string;
|
||||
planSnapshot: Record<string, unknown>;
|
||||
displacedBookingIds: string[];
|
||||
},
|
||||
manager?: EntityManager,
|
||||
): Promise<SchedulingEvent> {
|
||||
const repo = manager ? manager.getRepository(SchedulingEvent) : this.repository;
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ describe('SchedulingRescheduleService', () => {
|
||||
let bookingsRepository: Record<string, jest.Mock>;
|
||||
let trainSchedulingService: Record<string, jest.Mock>;
|
||||
let schedulingRescheduleRepository: Record<string, jest.Mock>;
|
||||
// Sentinel EntityManager the mocked dataSource.transaction hands to the
|
||||
// callback; executeReschedule threads it into updateStatus/createEvent.
|
||||
const txManager = {} as never;
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
trainSchedulesRepository = {
|
||||
@@ -72,6 +76,9 @@ describe('SchedulingRescheduleService', () => {
|
||||
schedulingRescheduleRepository = {
|
||||
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
|
||||
};
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: never) => unknown) => cb(txManager)),
|
||||
};
|
||||
|
||||
service = new SchedulingRescheduleService(
|
||||
trainSchedulesRepository as never,
|
||||
@@ -83,6 +90,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
removedFromTrain: jest.fn(),
|
||||
maintenanceMoved: jest.fn(),
|
||||
} as never, // notifier
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -212,7 +220,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
incomingBookingIds: ['c1'],
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
reason: 'Locomotive service',
|
||||
newDepartureDate: '2026-06-22T10:00:00.000Z',
|
||||
newDepartureDate: '2099-06-22T10:00:00.000Z',
|
||||
},
|
||||
'staff-1',
|
||||
);
|
||||
@@ -220,7 +228,8 @@ describe('SchedulingRescheduleService', () => {
|
||||
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
|
||||
'sched-1',
|
||||
'DRAFT',
|
||||
{ scheduledDepartureDate: new Date('2026-06-22T10:00:00.000Z') },
|
||||
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
|
||||
txManager,
|
||||
);
|
||||
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -228,6 +237,7 @@ describe('SchedulingRescheduleService', () => {
|
||||
actorUserId: 'staff-1',
|
||||
reason: 'Locomotive service',
|
||||
}),
|
||||
txManager,
|
||||
);
|
||||
expect(result.plan.trigger).toBe('TRAIN_MAINTENANCE');
|
||||
expect(result.plan.finalBookingIds).toEqual(['c1']);
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { SchedulingStatus, TrainScheduleStatus } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
@@ -12,6 +14,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||
|
||||
export interface RescheduleBookingSummary {
|
||||
@@ -40,6 +43,8 @@ export class SchedulingRescheduleService {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly schedulingRescheduleRepository: SchedulingRescheduleRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/** Preview who is retained, displaced, and readmitted on a schedule. */
|
||||
@@ -148,21 +153,38 @@ export class SchedulingRescheduleService {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
|
||||
if (dto.newDepartureDate && schedule) {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: new Date(dto.newDepartureDate) },
|
||||
);
|
||||
// M7: validate the requested departure BEFORE mutating anything, so a past
|
||||
// or malformed date is rejected up front rather than after (un)assign side
|
||||
// effects have already run. The actual write happens late (below), so a
|
||||
// failing (un)assign step never leaves the train visibly moved.
|
||||
let newDeparture: Date | null = null;
|
||||
if (dto.newDepartureDate) {
|
||||
newDeparture = new Date(dto.newDepartureDate);
|
||||
const now = new Date();
|
||||
if (Number.isNaN(newDeparture.getTime()) || newDeparture <= now) {
|
||||
throw new BadRequestException('New departure date must be in the future');
|
||||
}
|
||||
}
|
||||
|
||||
// H18: run the cross-service (un)assign steps FIRST. They own their own
|
||||
// transactions and are the steps most likely to fail, so doing them before
|
||||
// the date change + audit write means a failure aborts before anything of
|
||||
// ours is committed.
|
||||
for (const bookingId of dto.displacedBookingIds) {
|
||||
try {
|
||||
await this.trainSchedulingService.unassignBooking(scheduleId, bookingId);
|
||||
} catch {
|
||||
// M11: the unassign failed but this booking is being removed from the
|
||||
// train — also clear its schedule pointer, otherwise it stays linked
|
||||
// (stale trainScheduleId) and risks being double-booked. NOTE: the
|
||||
// TrainScheduleBooking link row / wagon allocations may still persist
|
||||
// (deleting those lives in TrainSchedulingService, not an injected repo
|
||||
// we own), so this is a best-effort detach; a human should finish the
|
||||
// link/allocation cleanup.
|
||||
await this.bookingsRepository.updateSchedulingFields(bookingId, {
|
||||
schedulingStatus: SchedulingStatus.Eligible,
|
||||
wagonsRequired: null,
|
||||
trainScheduleId: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -170,7 +192,7 @@ export class SchedulingRescheduleService {
|
||||
// A train can be rescheduled even with no bookings (e.g. moved for
|
||||
// maintenance). assignBookingsToSchedule requires at least one booking, so
|
||||
// only call it when something is actually being (re)assigned — the new
|
||||
// departure date above is the meaningful change for an empty train. The
|
||||
// departure date below is the meaningful change for an empty train. The
|
||||
// empty-train branch returns the same schedule-detail shape as the assign
|
||||
// path so callers get a consistent response.
|
||||
const assignResult = dto.finalBookingIds.length
|
||||
@@ -186,25 +208,50 @@ export class SchedulingRescheduleService {
|
||||
deferredBookings: [] as unknown[],
|
||||
};
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent({
|
||||
trainScheduleId: scheduleId,
|
||||
trigger: dto.trigger,
|
||||
actorUserId,
|
||||
reason: dto.reason,
|
||||
planSnapshot: plan as unknown as Record<string, unknown>,
|
||||
displacedBookingIds: dto.displacedBookingIds,
|
||||
// H18: apply the date change and write the audit record LAST, together, in a
|
||||
// single transaction over repositories we own (both updateStatus and
|
||||
// createEvent accept our manager, so the two writes commit or roll back as
|
||||
// one). RESIDUAL RISK: the cross-service (un)assign calls above are NOT
|
||||
// covered by this transaction — they run their own and cannot be threaded
|
||||
// through this manager without editing TrainSchedulingService. A failure
|
||||
// between those steps and this block can still leave partial state; a human
|
||||
// must finish the full cross-service transaction threading.
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (newDeparture) {
|
||||
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
|
||||
// delegate to TrainSchedulingService.updateScheduleDate, which only
|
||||
// permits a date change while windowPhase === 'PRE_WINDOW' and would
|
||||
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
|
||||
// the booking-window fields are NOT re-derived for the new date here.
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
schedule.status as TrainScheduleStatus,
|
||||
{ scheduledDepartureDate: newDeparture },
|
||||
manager,
|
||||
);
|
||||
}
|
||||
|
||||
await this.schedulingRescheduleRepository.createEvent(
|
||||
{
|
||||
trainScheduleId: scheduleId,
|
||||
trigger: dto.trigger,
|
||||
actorUserId,
|
||||
reason: dto.reason,
|
||||
planSnapshot: plan as unknown as Record<string, unknown>,
|
||||
displacedBookingIds: dto.displacedBookingIds,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
// Notify affected customers (SMS + email). Best-effort — a notification
|
||||
// failure must never fail the reschedule, so each send is fire-and-forget
|
||||
// inside the notifier. Government pre-empt already notifies via the batch
|
||||
// displaced() path, so skip removed-from-train notices for that trigger.
|
||||
// Use the new departure date when the reschedule moved it (the in-memory
|
||||
// `schedule` still holds the pre-update date).
|
||||
const effectiveDeparture = dto.newDepartureDate
|
||||
? new Date(dto.newDepartureDate)
|
||||
: schedule.scheduledDepartureDate;
|
||||
await this.notifyRescheduleOutcome(dto, effectiveDeparture);
|
||||
// M12: only announce a new departure when the date actually moved —
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
}
|
||||
@@ -256,17 +303,26 @@ export class SchedulingRescheduleService {
|
||||
/** Maintenance shortcut: new departure + rebalance. */
|
||||
async maintenanceReschedule(
|
||||
scheduleId: string,
|
||||
dto: PreviewRescheduleDto & { newDepartureDate: string },
|
||||
dto: MaintenanceRescheduleDto,
|
||||
actorUserId?: string,
|
||||
) {
|
||||
const currentIds = (
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId)
|
||||
)?.scheduleBookings?.map((l) => l.bookingId) ?? [];
|
||||
|
||||
// M13: merge the bookings already on the train with any caller-supplied
|
||||
// incoming ids and feed the SAME set to both preview and execute. The old
|
||||
// code dropped the caller's ids whenever the train was non-empty (preview)
|
||||
// and then executed against a different (raw) set, so the previewed plan and
|
||||
// the executed plan could diverge.
|
||||
const mergedIncomingIds = Array.from(
|
||||
new Set([...currentIds, ...(dto.incomingBookingIds ?? [])]),
|
||||
);
|
||||
|
||||
const preview = await this.previewReschedule(scheduleId, {
|
||||
...dto,
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
incomingBookingIds: currentIds.length ? currentIds : dto.incomingBookingIds,
|
||||
incomingBookingIds: mergedIncomingIds,
|
||||
});
|
||||
|
||||
return this.executeReschedule(
|
||||
@@ -274,7 +330,7 @@ export class SchedulingRescheduleService {
|
||||
{
|
||||
...dto,
|
||||
trigger: 'TRAIN_MAINTENANCE',
|
||||
incomingBookingIds: dto.incomingBookingIds,
|
||||
incomingBookingIds: mergedIncomingIds,
|
||||
finalBookingIds: preview.finalBookingIds,
|
||||
displacedBookingIds: preview.displaced.map((b) => b.id),
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ export interface SchedulingPriorityBooking {
|
||||
isGovernment?: boolean;
|
||||
priorityScore?: number | null;
|
||||
// One-time bookings always carry a date; general contracts (never scheduled)
|
||||
// may be null — treated as epoch 0 so they sort last.
|
||||
// may be null — treated as the far future (MAX_SAFE_INTEGER) so they sort last.
|
||||
scheduledDate?: Date | string | null;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ export function compareSchedulingPriority(
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
const aTime = a.scheduledDate
|
||||
? new Date(a.scheduledDate).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const bTime = b.scheduledDate
|
||||
? new Date(b.scheduledDate).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return aTime - bTime;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { TrackingService } from "./tracking.service";
|
||||
|
||||
@ApiTags("tracking")
|
||||
@ApiBearerAuth()
|
||||
@Controller("tracking")
|
||||
@BookingStaff(FREIGHT_PERMS.tracking.view)
|
||||
export class TrackingController {
|
||||
constructor(private readonly trackingService: TrackingService) {}
|
||||
|
||||
@Get(":consignmentId")
|
||||
@ApiOperation({ summary: "Get the tracking timeline for a consignment" })
|
||||
// TODO: scope to the caller's company — a staffer with tracking:view can
|
||||
// currently read any consignment's timeline. Add company-ownership filtering
|
||||
// once the ownership helper is wired into this module.
|
||||
findByConsignment(
|
||||
@Param("consignmentId", ParseUUIDPipe) consignmentId: string,
|
||||
) {
|
||||
|
||||
@@ -769,7 +769,54 @@ export class BookingBatchService implements OnModuleInit {
|
||||
bookings: Booking[],
|
||||
scheduleId: string,
|
||||
): Promise<void> {
|
||||
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||
// H8: the capacity check (pickExportSchedule → budget.fits) and the
|
||||
// reservation writes below are not atomic on their own — two concurrent
|
||||
// export accepts can each see the same train as fitting and both reserve,
|
||||
// overshooting the train's capacity. Serialize reservations against this
|
||||
// schedule: take a pessimistic_write lock on the TrainSchedule row
|
||||
// (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings'
|
||||
// combined need from freshly-committed state INSIDE the lock before the
|
||||
// reserve writes run. A loser (another accept took the space first) gets a
|
||||
// ConflictException — the staff accept fails and reverts, exactly as an
|
||||
// up-front full train does. Covered: the fits-vs-reserve overshoot on the
|
||||
// export FCFS path; the lock is held for the duration of the reserve writes.
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const locked = await manager.findOne(TrainSchedule, {
|
||||
where: { id: scheduleId },
|
||||
lock: { mode: "pessimistic_write" },
|
||||
});
|
||||
if (!locked) {
|
||||
throw new ConflictException(
|
||||
"Export train is no longer available for reservation",
|
||||
);
|
||||
}
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) {
|
||||
throw new ConflictException(
|
||||
"Export train is no longer available for reservation",
|
||||
);
|
||||
}
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(
|
||||
bookings[0].originYardId,
|
||||
bookings[0].destinationYardId,
|
||||
);
|
||||
const need =
|
||||
bookings.length >= 2
|
||||
? this.combinedNeed(bookings[0], bookings[1], wagonDims)
|
||||
: this.needFor(bookings[0], wagonDims);
|
||||
if (!leg || !budget.fits(need, leg)) {
|
||||
throw new ConflictException(
|
||||
"Train is full — no export capacity left for this day",
|
||||
);
|
||||
}
|
||||
|
||||
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||
});
|
||||
this.armSettle(scheduleId);
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
|
||||
@@ -1522,22 +1522,48 @@ export class TrainSchedulingService {
|
||||
manager,
|
||||
);
|
||||
|
||||
const remainingBookings = (schedule.scheduleBookings ?? []).filter(
|
||||
(sb) => sb.bookingId !== bookingId,
|
||||
);
|
||||
if (remainingBookings.length === 0) {
|
||||
await this.wagonBookingAllocationsRepository.deleteByTrainSetId(
|
||||
schedule.trainSetId,
|
||||
manager,
|
||||
// Recompute the train-set composition from whatever survives this removal.
|
||||
// The removed booking's allocations were already deleted above, so any slot
|
||||
// left with zero allocations was ridden only by this booking — release it
|
||||
// (frees its reserved wagon slot). Shared slots keep their surviving
|
||||
// allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
|
||||
// and orphaned RESERVED slots on a PARTIAL unassign (previously only the
|
||||
// fully-empty train was reset).
|
||||
const survivingSlots = await manager.getRepository(TrainSetWagon).find({
|
||||
where: { trainSetId: schedule.trainSetId },
|
||||
relations: { allocations: true },
|
||||
});
|
||||
let recomputedWeightTons = 0;
|
||||
let recomputedLengthMeters = 0;
|
||||
let recomputedWagonCount = 0;
|
||||
for (const slot of survivingSlots) {
|
||||
const slotAllocations = slot.allocations ?? [];
|
||||
if (slotAllocations.length === 0) {
|
||||
await manager.getRepository(TrainSetWagon).delete(slot.id);
|
||||
continue;
|
||||
}
|
||||
const slotWeight = slotAllocations.reduce(
|
||||
(sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
await manager.getRepository(TrainSetWagon).delete({ trainSetId: schedule.trainSetId });
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||||
totalWeightTons: 0,
|
||||
totalLengthMeters: 0,
|
||||
wagonCount: 0,
|
||||
status: 'DRAFT',
|
||||
});
|
||||
if (Number(slot.assignedWeightTons) !== slotWeight) {
|
||||
await manager
|
||||
.getRepository(TrainSetWagon)
|
||||
.update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
|
||||
}
|
||||
recomputedWeightTons += slotWeight;
|
||||
recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
|
||||
recomputedWagonCount += 1;
|
||||
}
|
||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
|
||||
totalWeightTons: roundTons(recomputedWeightTons),
|
||||
totalLengthMeters: roundTons(recomputedLengthMeters),
|
||||
wagonCount: recomputedWagonCount,
|
||||
// Only downgrade to DRAFT once the train is fully empty; otherwise keep
|
||||
// the current status (an object literal lets TypeORM's contextual typing
|
||||
// accept the partial without pulling in relation fields).
|
||||
...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
await this.trainCompositionRemovalLogRepository.create({
|
||||
@@ -3228,6 +3254,14 @@ export class TrainSchedulingService {
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
if (
|
||||
schedule.status !== TrainScheduleStatusEnum.Draft &&
|
||||
schedule.status !== TrainScheduleStatusEnum.Scheduled
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -3397,6 +3431,27 @@ export class TrainSchedulingService {
|
||||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||
}
|
||||
|
||||
// Day-match: a booking scheduled for a specific EAT day must board a train
|
||||
// departing that same day. forceAssign downgrades a mismatch to a warning
|
||||
// so staff can knowingly move a booking onto an adjacent-day train.
|
||||
const scheduleDay = eatDay(new Date(dto.scheduleDate));
|
||||
const dayMismatched = bookings.filter(
|
||||
(b) =>
|
||||
!(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
|
||||
b.scheduledDate != null &&
|
||||
eatDay(new Date(b.scheduledDate)) !== scheduleDay,
|
||||
);
|
||||
if (dayMismatched.length) {
|
||||
const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
|
||||
.map((b) => b.reference ?? b.id)
|
||||
.join(', ')}`;
|
||||
if (forceAssign) {
|
||||
warnings.push(message);
|
||||
} else {
|
||||
violations.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!forceAssign) {
|
||||
for (const booking of bookings) {
|
||||
if (this.isHoldActive(booking)) {
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { DataSource, FindOptionsOrder, FindOptionsWhere, ILike, Repository } from 'typeorm';
|
||||
import { CreateTrainDto } from './dto/create-train.dto';
|
||||
import { UpdateTrainDto } from './dto/update-train.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TrainsService {
|
||||
constructor(
|
||||
@InjectRepository(Train)
|
||||
private readonly trainRepo: Repository<Train>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
create(dto: CreateTrainDto): Promise<Train> {
|
||||
@@ -54,8 +58,40 @@ export class TrainsService {
|
||||
return this.trainRepo.save(train);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a built train. Blocked while it still has a live (DRAFT/SCHEDULED/
|
||||
* DISPATCHED) schedule; otherwise its wagons are freed (back to AVAILABLE)
|
||||
* and its locomotive links dropped so nothing is stranded, then the train is
|
||||
* soft-deleted. Mirrors TrainBuilderService.disband but prefers softRemove.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
const train = await this.findById(id);
|
||||
await this.trainRepo.remove(train);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
|
||||
const active: { count: string }[] = await manager.query(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
||||
[id],
|
||||
);
|
||||
if (Number(active[0]?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
'Train has active schedules; cancel them before deleting the train',
|
||||
);
|
||||
}
|
||||
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ trainId: train.id },
|
||||
{ trainId: null, sequenceNumber: null, status: WagonStatus.Available },
|
||||
);
|
||||
await manager.getRepository(TrainLocomotive).delete({ trainId: train.id });
|
||||
await manager.getRepository(Train).softRemove(train);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { CreateWagonDto } from './create-wagon.dto';
|
||||
|
||||
export class UpdateWagonDto extends PartialType(CreateWagonDto) {}
|
||||
// `trainId` and `sequenceNumber` are owned by the assign/train-builder flow and
|
||||
// must never be settable through a generic wagon PATCH — omit them here.
|
||||
export class UpdateWagonDto extends PartialType(
|
||||
OmitType(CreateWagonDto, ['trainId', 'sequenceNumber'] as const),
|
||||
) {}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
@@ -89,6 +94,20 @@ export class WagonsService {
|
||||
|
||||
async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
|
||||
const wagon = await this.findById(id);
|
||||
// A wagon coupled to a built train follows the train: its yard and status
|
||||
// are managed through the train-builder flow, not this generic PATCH.
|
||||
if (wagon.trainId != null) {
|
||||
if (dto.currentYardId !== undefined && dto.currentYardId !== wagon.currentYardId) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a built train; relocate the train (train-builder) instead of moving the wagon`,
|
||||
);
|
||||
}
|
||||
if (dto.status !== undefined && dto.status !== wagon.status) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before changing its status`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
Object.assign(wagon, dto);
|
||||
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
|
||||
@@ -135,36 +154,96 @@ export class WagonsService {
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const wagon = await this.findById(id);
|
||||
await this.wagonRepo.remove(wagon);
|
||||
// A coupled wagon must be detached via train-builder before it can be
|
||||
// removed, so a built train never silently loses a wagon.
|
||||
if (wagon.trainId != null) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is coupled to a built train; detach it via train-builder before deleting it`,
|
||||
);
|
||||
}
|
||||
if (await this.isWagonPinnedToLiveSchedule(id)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be deleted`,
|
||||
);
|
||||
}
|
||||
// Soft delete (deleted_at) — hard-deleting would strand ledger/schedule
|
||||
// history that references this wagon.
|
||||
await this.wagonRepo.softRemove(wagon);
|
||||
}
|
||||
|
||||
/**
|
||||
* A wagon is busy when any live (DRAFT/SCHEDULED/DISPATCHED) schedule pins it
|
||||
* to one of its slots — schedule occupancy lives on TrainSetWagon rows, not
|
||||
* on the Wagon entity. Mirrors TrainBuilderService.isWagonPinnedToLiveSchedule.
|
||||
*/
|
||||
private async isWagonPinnedToLiveSchedule(wagonId: string): Promise<boolean> {
|
||||
const rows: { exists: boolean }[] = await this.dataSource.query(
|
||||
`SELECT TRUE AS exists
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
if (wagon.status === WagonStatus.Assigned) {
|
||||
throw new ConflictException('Wagon already assigned to a train');
|
||||
// Mirror train-builder attachWagons: only a truly free, available wagon in
|
||||
// the train's own yard can be coupled, and never onto a dispatched train.
|
||||
if (wagon.trainId != null) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`);
|
||||
}
|
||||
if (wagon.status !== WagonStatus.Available) {
|
||||
throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`);
|
||||
}
|
||||
|
||||
const train = await this.trainRepo.findOne({ where: { id: dto.trainId } });
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
if (wagon.currentYardId !== train.currentYardId) {
|
||||
throw new BadRequestException(
|
||||
`Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`,
|
||||
);
|
||||
}
|
||||
|
||||
let sequence: number | null = dto.sequenceNumber ?? null;
|
||||
if (sequence === null) {
|
||||
const maxSeq = await this.wagonRepo
|
||||
.createQueryBuilder('w')
|
||||
.select('MAX(w.sequenceNumber)', 'max')
|
||||
.where('w.trainId = :trainId', { trainId: train.id })
|
||||
.getRawOne();
|
||||
sequence = (maxSeq?.max ?? 0) + 1;
|
||||
const maxSeq = await this.wagonRepo
|
||||
.createQueryBuilder('w')
|
||||
.select('MAX(w.sequenceNumber)', 'max')
|
||||
.where('w.trainId = :trainId', { trainId: train.id })
|
||||
.getRawOne();
|
||||
const nextSequence = Number(maxSeq?.max ?? 0) + 1;
|
||||
// An explicit sequence is only honoured when it is the next free slot;
|
||||
// anything else would duplicate a slot or leave a gap.
|
||||
if (dto.sequenceNumber != null && dto.sequenceNumber !== nextSequence) {
|
||||
throw new BadRequestException(
|
||||
`Sequence ${dto.sequenceNumber} is not the next free slot (${nextSequence}) for train ${train.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
wagon.trainId = train.id;
|
||||
wagon.sequenceNumber = sequence;
|
||||
wagon.sequenceNumber = nextSequence;
|
||||
wagon.status = WagonStatus.Assigned;
|
||||
return this.wagonRepo.save(wagon);
|
||||
}
|
||||
|
||||
async unassignFromTrain(wagonId: string): Promise<Wagon> {
|
||||
const wagon = await this.findById(wagonId);
|
||||
// A wagon pinned to a live schedule is still operationally committed even
|
||||
// if the fleet train is being edited — don't free it out from under it.
|
||||
if (await this.isWagonPinnedToLiveSchedule(wagonId)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`,
|
||||
);
|
||||
}
|
||||
wagon.trainId = null;
|
||||
wagon.sequenceNumber = null;
|
||||
wagon.status = WagonStatus.Available;
|
||||
@@ -201,6 +280,19 @@ export class WagonsService {
|
||||
throw new NotFoundException('One or more wagons not found');
|
||||
}
|
||||
|
||||
// Only free, available wagons can be bulk-relocated; a coupled wagon
|
||||
// moves with its train (train-builder), never on its own here.
|
||||
const blocked = wagons.filter(
|
||||
(w) => w.trainId != null || w.status !== WagonStatus.Available,
|
||||
);
|
||||
if (blocked.length) {
|
||||
throw new ConflictException(
|
||||
`Cannot transfer wagons coupled to a train or not available: ${blocked
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
let moved = 0;
|
||||
for (const wagon of wagons) {
|
||||
const previousYardId = wagon.currentYardId ?? null;
|
||||
@@ -253,6 +345,17 @@ export class WagonsService {
|
||||
throw new NotFoundException('One or more wagons not found');
|
||||
}
|
||||
|
||||
// A coupled wagon's status is owned by the train-builder flow — refuse to
|
||||
// flip status on any wagon that is currently on a built train.
|
||||
const coupled = wagons.filter((w) => w.trainId != null);
|
||||
if (coupled.length) {
|
||||
throw new ConflictException(
|
||||
`Cannot change status of wagons coupled to a built train: ${coupled
|
||||
.map((w) => w.wagonNumber)
|
||||
.join(', ')}. Detach them via train-builder first.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const wagon of wagons) {
|
||||
wagon.status = status;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,13 @@ function usedWeight(schedule: TrainScheduleDetail): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Max pull weight across all locomotives on the set (0 when unknown). */
|
||||
/**
|
||||
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
|
||||
* unknown). The API caps at the weakest loco, not the sum of all locos — a
|
||||
* consist can only pull as hard as its weakest engine. Note: the API also adds
|
||||
* the consist tare to the used weight when it checks this cap; tare isn't
|
||||
* available client-side, so this meter compares cargo-only load against pull.
|
||||
*/
|
||||
function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
const set = schedule.trainSet;
|
||||
if (!set) return 0;
|
||||
@@ -105,7 +111,8 @@ function pullCapacity(schedule: TrainScheduleDetail): number {
|
||||
: set.locomotive
|
||||
? [set.locomotive]
|
||||
: [];
|
||||
return locos.reduce((sum, l) => sum + (Number(l.maxPullWeightTons) || 0), 0);
|
||||
if (locos.length === 0) return 0;
|
||||
return Math.min(...locos.map((l) => Number(l.maxPullWeightTons) || 0));
|
||||
}
|
||||
|
||||
export function ScheduleWorkspacePanel({
|
||||
|
||||
@@ -19,7 +19,7 @@ export function AssignWagonDialog({ trainId }: { trainId: string }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const available = (wagons ?? []).filter(
|
||||
(w) => w.status === Freight.WagonStatus.Available || !w.trainId,
|
||||
(w) => w.status === Freight.WagonStatus.Available && !w.trainId,
|
||||
);
|
||||
|
||||
const yardLabelById = useMemo(
|
||||
|
||||
@@ -183,10 +183,22 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
||||
return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId);
|
||||
}, [yardWagons, yardId, typeId]);
|
||||
|
||||
const availableWagons = useMemo(() => matching.filter((w) => w.status === AVAILABLE), [matching]);
|
||||
const assignedWagons = useMemo(() => matching.filter((w) => w.status === ASSIGNED), [matching]);
|
||||
// Wagons coupled to a built train (trainId set) are managed through the
|
||||
// train-builder flow — they can't be bulk-flipped or transferred here, so
|
||||
// keep them out of the action pools and bucket them under "Other".
|
||||
const availableWagons = useMemo(
|
||||
() => matching.filter((w) => w.status === AVAILABLE && !w.trainId),
|
||||
[matching],
|
||||
);
|
||||
const assignedWagons = useMemo(
|
||||
() => matching.filter((w) => w.status === ASSIGNED && !w.trainId),
|
||||
[matching],
|
||||
);
|
||||
const otherWagons = useMemo(
|
||||
() => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED),
|
||||
() =>
|
||||
matching.filter(
|
||||
(w) => w.trainId != null || (w.status !== AVAILABLE && w.status !== ASSIGNED),
|
||||
),
|
||||
[matching],
|
||||
);
|
||||
const total = matching.length;
|
||||
|
||||
@@ -298,7 +298,17 @@ const FleetResourcePage = () => {
|
||||
const handleFormSubmit = async (values: Record<string, unknown>) => {
|
||||
try {
|
||||
if (editing && "id" in editing) {
|
||||
await update.mutateAsync({ slug, id: String(editing.id), data: values });
|
||||
// PATCH only the fields the user actually changed. Re-sending the whole
|
||||
// form used to re-submit status/currentYardId on every save — which,
|
||||
// for a locomotive/wagon coupled to a built train, silently diverged
|
||||
// the consist (editing a name could move the loco to another yard).
|
||||
const editingRecord = editing as unknown as Record<string, unknown>;
|
||||
const changed = Object.fromEntries(
|
||||
Object.entries(values).filter(
|
||||
([key, value]) => value !== editingRecord[key],
|
||||
),
|
||||
);
|
||||
await update.mutateAsync({ slug, id: String(editing.id), data: changed });
|
||||
toast({ title: `${config.entityLabel} updated` });
|
||||
} else {
|
||||
await create.mutateAsync({ slug, data: values });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -65,11 +65,6 @@ function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">(
|
||||
"TELEBIRR",
|
||||
);
|
||||
console.log(paymentMethod)
|
||||
|
||||
const {
|
||||
data: invoice,
|
||||
isLoading,
|
||||
@@ -93,11 +88,6 @@ export default function InvoiceDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!invoice) return;
|
||||
setPaymentMethod(invoice.currency === "USD" ? "WAAFI" : "TELEBIRR");
|
||||
}, [invoice?.currency]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py={80}>
|
||||
@@ -128,7 +118,9 @@ export default function InvoiceDetailPage() {
|
||||
|
||||
const payable = isPayable(invoice.status);
|
||||
const lines = invoice.lines ?? [];
|
||||
// const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
// The backend charges the outstanding balance, not the invoice total — a
|
||||
// partially-paid invoice must show the remaining amount on the pay button.
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
|
||||
const handlePay = () => {
|
||||
setPayModalOpen(true);
|
||||
@@ -263,8 +255,7 @@ export default function InvoiceDetailPage() {
|
||||
root: { fontWeight: 600, height: 42, paddingInline: 18 },
|
||||
}}
|
||||
>
|
||||
Pay{" "}
|
||||
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||
Pay {formatCurrency(amountDue, invoice.currency)}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -390,10 +381,7 @@ export default function InvoiceDetailPage() {
|
||||
payMutation.reset();
|
||||
}
|
||||
}}
|
||||
amountLabel={formatCurrency(
|
||||
Number(invoice.totalAmount),
|
||||
invoice.currency,
|
||||
)}
|
||||
amountLabel={formatCurrency(amountDue, invoice.currency)}
|
||||
currency={invoice.currency}
|
||||
processing={payMutation.isPending}
|
||||
error={
|
||||
|
||||
@@ -120,7 +120,9 @@ export function PayClearanceFeeButton({
|
||||
onClose={pay.close}
|
||||
amountLabel={
|
||||
pay.invoice
|
||||
? `${Number(pay.invoice.totalAmount).toLocaleString()} ${pay.invoice.currency}`
|
||||
? `${Number(
|
||||
pay.invoice.balanceAmount ?? pay.invoice.totalAmount,
|
||||
).toLocaleString()} ${pay.invoice.currency}`
|
||||
: undefined
|
||||
}
|
||||
currency={pay.invoice?.currency ?? currency}
|
||||
|
||||
Reference in New Issue
Block a user