From e5d2bb2f637367de5d5eb7d9b95657e58136c413 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 9 Jul 2026 12:51:12 +0000 Subject: [PATCH 01/75] fix: unused var --- .../edr-freight-web/portal/src/pages/contracts/ContractsList.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index b6ba870d6..40a519840 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -33,7 +33,6 @@ import { X, } from "lucide-react"; -import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import { ContractCustomerAction } from "@/components/customer-actions/ContractCustomerAction"; From 1690f9e498515e6e26a6ba44d84dcc52b3aeac28 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 15:30:00 +0000 Subject: [PATCH 02/75] Enhance bulk booking handling and wagon capacity calculations across services --- .../bookings/booking-pricing.service.ts | 12 ++- .../booking-batch.service.spec.ts | 12 +++ .../train-scheduling/booking-batch.service.ts | 92 ++++++++++++++++++- .../train-scheduling/booking-split.service.ts | 14 ++- .../train-capacity.util.spec.ts | 51 ++++++++++ .../train-scheduling/train-capacity.util.ts | 36 ++++++++ .../train-scheduling.service.ts | 2 +- .../train-scheduling/wagon-plan.util.spec.ts | 23 +++++ .../train-scheduling/wagon-plan.util.ts | 16 +++- .../trainScheduling/PriorityTrackingTab.tsx | 26 ++++-- .../BatchScheduleDetailPage.tsx | 26 +++--- 11 files changed, 278 insertions(+), 32 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index d63106c2b..00cf55e17 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -400,8 +400,18 @@ export class BookingPricingService { * All three components are produced by RuleEngineService.evaluate, so submit * simply re-runs the engine — there is no extra submit-time inflation. */ - async computeSubmitPriorityScore(booking: Booking): Promise { + async computeSubmitPriorityScore( + booking: Booking, + totalWagonsOverride?: number, + ): Promise { const evalInput = await this.buildEvalInputForBooking(booking); + // BULK bookings have no container lines, so buildEvalInputForBooking yields + // totalWagons = 0 and every wagon-range priority config misses. The batch + // engine derives a bulk booking's wagon footprint from tonnage vs. live + // wagon capacity and passes it here to score the booking properly. + if (totalWagonsOverride != null && totalWagonsOverride > 0) { + evalInput.totalWagons = totalWagonsOverride; + } const ruleResult = await this.ruleEngineService.evaluate(evalInput); return ruleResult.priorityScore; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index a20ef600d..60e2b6767 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -136,6 +136,7 @@ describe('BookingBatchService — PAID reconcile', () => { expirePayable: jest.fn().mockResolvedValue(undefined), } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, ); }); @@ -563,6 +564,7 @@ describe('BookingBatchService — PAID reconcile', () => { trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, { findOpenOffer: jest.fn() } as never, ); @@ -585,6 +587,7 @@ describe('BookingBatchService — PAID reconcile', () => { trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, { findOpenOffer: jest.fn() } as never, ); @@ -615,6 +618,7 @@ describe('BookingBatchService — PAID reconcile', () => { trainSchedulingService as never, { syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never, { emitPhase: jest.fn() } as never, + { computeSubmitPriorityScore: jest.fn().mockResolvedValue(0) } as never, undefined, { findOpenOffer: jest.fn() } as never, ); @@ -745,6 +749,7 @@ describe('BookingBatchService — wagonsFor', () => { null as never, null as never, null as never, + null as never, ) as unknown as { wagonsFor(booking: unknown, dims: unknown): number; }; @@ -780,6 +785,13 @@ describe('BookingBatchService — wagonsFor', () => { expect(service.wagonsFor(bulk(2590, { wagonsRequired: 40 }), dims)).toBe(40); }); + it('ignores a stale undersized wagonsRequired: 700T of sugar rides 10 wagons, not 1', () => { + // Rows written while sumWagonsRequired hardcoded BULK to 1 are still in the + // DB; trusting them charged one tare for the whole consist (700 + 25.2 + // instead of 700 + 10 × 25.2 gross). + expect(service.wagonsFor(bulk(700, { wagonsRequired: 1 }), dims)).toBe(10); + }); + it('takes the binding axis for containers: weight can exceed TEU geometry', () => { // Two 40ft units => 2 wagons by TEU geometry, but 210T needs 3 at 70T each. const booking = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 93227f3ab..cf710e429 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1,6 +1,8 @@ import { BadRequestException, ConflictException, + forwardRef, + Inject, Injectable, Logger, NotFoundException, @@ -13,6 +15,7 @@ import { DataSource, In } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; @@ -42,6 +45,7 @@ import { bookingGrossWeightTons, bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, + sizePartialOfferWagons, trainHardCaps, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; @@ -239,6 +243,8 @@ export class BookingBatchService implements OnModuleInit { private readonly trainSchedulingService: TrainSchedulingService, private readonly billing: BillingService, private readonly bookingWindowGateway: BookingWindowGateway, + @Inject(forwardRef(() => BookingPricingService)) + private readonly pricingService: BookingPricingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, @@ -1097,6 +1103,10 @@ export class BookingBatchService implements OnModuleInit { } const pool = await this.bookingsRepository.findBatchPool(scheduleId); + // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill + // must rank bulk bookings by their wagon-derived priority too. + await this.recomputeBulkPriorities(pool, wagonDims); + this.resortPoolByPriority(pool); const units = this.groupConsolidatedPool(pool); let armed = false; let reservedThisPass = 0; @@ -1314,6 +1324,10 @@ export class BookingBatchService implements OnModuleInit { corridorYards, day, ); + // BULK bookings only get their real (wagon-derived) priority score now, at + // batch time — stamp it and re-rank before the fill consumes the pool. + await this.recomputeBulkPriorities(pool, wagonDims); + this.resortPoolByPriority(pool); // Consolidated partners collapse into one atomic unit (both-or-neither); a // consolidated booking whose partner isn't ready this cycle is skipped. const units = this.groupConsolidatedPool(pool); @@ -1505,11 +1519,24 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); + + // The wagon-slot axis alone under-constrains the offer. On a weight- or + // length-limited train (slots to spare, but e.g. only 798T of pull weight + // left) sizing by slots either produced an offer the fits() check below + // rejected, or — when the free slots exceeded the booking's own wagon + // count — sizeOffer refused outright, so a bulk booking on a weight-bound + // train was never offered a split at all. Size across all three axes. + const perWagon = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const partial = sizePartialOfferWagons(budget, need.wagons, perWagon); + if (!partial) return null; + const sized = await this.splitService.sizeOffer( booking, - budget.wagons, + partial.wagons, need.wagons, bulkCapacityTons, + partial.maxCargoTons, ); if (!sized) return null; @@ -2284,6 +2311,56 @@ export class BookingBatchService implements OnModuleInit { }; } + /** + * Stamp real priority scores on the pool's BULK bookings before the batch + * ranks it. Submit-time scoring runs with totalWagons = 0 for bulk (a bulk + * booking has no container lines to carry a wagon count), so every + * wagon-range priority config missed and bulk import bookings entered the + * batch at score 0 — they were never prioritized. Their wagon footprint is + * derivable from tonnage vs. live wagon capacity (wagonsFor), so the score + * is computed here — when doc review closes and the batch runs — and + * persisted so the priority board shows the same ranking. The pool arrives + * SQL-ordered by the old scores; the caller must re-sort after this. + */ + private async recomputeBulkPriorities( + pool: Booking[], + wagonDims: WagonDims, + ): Promise { + for (const booking of pool) { + if (booking.freightType !== 'BULK') continue; + try { + const wagons = this.wagonsFor(booking, wagonDims); + const score = await this.pricingService.computeSubmitPriorityScore( + booking, + wagons, + ); + if (Number(booking.priorityScore ?? 0) === score) continue; + await this.dataSource + .getRepository(Booking) + .update(booking.id, { priorityScore: score }); + booking.priorityScore = score; + } catch (err) { + // A failed recompute keeps the stored score — never blocks the batch. + this.logger.warn( + `Bulk priority recompute failed for ${booking.reference ?? booking.id}: ` + + `${(err as Error).message}`, + ); + } + } + } + + /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */ + private resortPoolByPriority(pool: Booking[]): void { + pool.sort( + (a, b) => + Number(b.isGovernment) - Number(a.isGovernment) || + Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) || + (a.fullyExecutedAt?.getTime() ?? Infinity) - + (b.fullyExecutedAt?.getTime() ?? Infinity) || + a.createdAt.getTime() - b.createdAt.getTime(), + ); + } + /** * Wagons a booking occupies. Two axes bind independently and the booking needs * enough wagons to satisfy BOTH, so the count is the larger of: @@ -2298,9 +2375,14 @@ export class BookingBatchService implements OnModuleInit { * That under-reported the board and let the fill loop overbook the train. */ private wagonsFor(booking: Booking, wagonDims: WagonDims): number { - if (booking.wagonsRequired && booking.wagonsRequired > 0) { - return Math.ceil(booking.wagonsRequired); - } + // Stored wagonsRequired is a candidate, never an early return: rows written + // while sumWagonsRequired hardcoded BULK to 1 wagon are still in the DB, and + // trusting them charged one tare for a whole bulk consist (a 700T booking on + // 70T wagons read 700 + 1 tare instead of 700 + 10 tares). + const stored = + booking.wagonsRequired && booking.wagonsRequired > 0 + ? Math.ceil(booking.wagonsRequired) + : 0; // TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. @@ -2311,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit { const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; - return Math.max(DEFAULT_WAGONS_PER_BOOKING, byLength, byWeight); + return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); } private capacityFor( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 44886f116..104db4545 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -55,12 +55,17 @@ export class BookingSplitService { * Size the largest part of the booking that fits `freeWagons`, priced via an * in-memory clone. Returns null when nothing meaningful fits (no whole * container unit / no bulk tonnage, or pricing failed). + * + * `maxOfferedWeightTons` caps the offered CARGO tonnage (bulk only) — on a + * weight-limited train the wagons' own tare eats into the locomotive's + * remaining pull weight, so the caller passes the room left after tare. */ async sizeOffer( booking: Booking, freeWagons: number, totalWagons: number, bulkWagonCapacityTons: number, + maxOfferedWeightTons?: number, ): Promise { if (freeWagons < 1 || freeWagons >= totalWagons) return null; @@ -110,10 +115,15 @@ export class BookingSplitService { if (!offeredLines.length || offeredWagons <= 0) return null; clone.bookingContainers = clonedContainers; } else { - // Bulk: split by weight — the offered part is what freeWagons can carry. + // Bulk: split by weight — the offered part is what freeWagons can carry, + // further capped by the caller's weight room when the pull limit binds. const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; - offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); + offeredWeightTons = Math.min( + totalWeight, + freeWagons * bulkWagonCapacityTons, + maxOfferedWeightTons ?? Number.POSITIVE_INFINITY, + ); if (offeredWeightTons <= 0) return null; offeredWagons = Math.min( freeWagons, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index e1b4064bb..cd0411831 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -6,6 +6,7 @@ import { deriveTrainCapacityFromLocomotive, grossWagonWeightTons, minLocomotiveLimits, + sizePartialOfferWagons, } from './train-capacity.util'; describe('train-capacity.util', () => { @@ -185,4 +186,54 @@ describe('train-capacity.util', () => { expect(limits?.maxPullWeightTons).toBe(3500); expect(limits?.overageToleranceTons).toBe(20); }); + + describe('sizePartialOfferWagons', () => { + it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => { + // The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2 + // wagons: 1000 + 378 tare = 1378), leaving 744T of pull weight but plenty + // of slots/length. The boundary 1000T booking (15 wagons) must be offered + // the largest part 744T can carry: 8 wagons whose tare is 201.6T, hauling + // 542.4T of cargo — gross exactly 744. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 744, lengthMeters: 500 }, + 15, + pw2, + ); + expect(offer).toEqual({ wagons: 8, maxCargoTons: 542.4 }); + }); + + it('still sizes by wagon slots when they bind first (legacy behavior)', () => { + const offer = sizePartialOfferWagons( + { wagons: 3, weightTons: 100000, lengthMeters: 100000 }, + 15, + pw2, + ); + expect(offer?.wagons).toBe(3); + }); + + it('sizes by the LENGTH axis when it binds first', () => { + // 60m of train left → 3 PW2 (17.066m) fit, the 4th does not. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 100000, lengthMeters: 60 }, + 15, + pw2, + ); + expect(offer?.wagons).toBe(3); + }); + + it('never offers all of the booking — a split is a strict subset', () => { + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 100000, lengthMeters: 100000 }, + 15, + pw2, + ); + expect(offer?.wagons).toBe(14); + }); + + it('returns null when not even one part-loaded wagon fits the weight room', () => { + expect( + sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2), + ).toBeNull(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 79adf8d8f..372b29357 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -256,6 +256,42 @@ export function bookingGrossWeightTons( return round3(num(cargoTons) + wagonCount * num(tarePerWagonTons)); } +/** + * Size a partial (split-on-payment) offer against the room left on a train, + * across ALL THREE capacity axes — not just wagon slots. Each wagon adds + * `capacityTons` of payload headroom but its own tare spends the same weight + * room the cargo needs, so on a weight-limited train more wagons is not always + * more cargo. Scans wagon counts (the last wagon may run part-loaded) and + * returns the count that maximizes the cargo carried, with the cargo cap the + * caller should apply. Null when not even one part-loaded wagon fits. The + * offer is a strict subset of the booking: never all `bookingWagons`. + */ +export function sizePartialOfferWagons( + room: { wagons: number; weightTons: number; lengthMeters: number }, + bookingWagons: number, + perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number }, +): { wagons: number; maxCargoTons: number } | null { + const maxByLength = + perWagon.lengthMeters > 0 + ? Math.floor(room.lengthMeters / perWagon.lengthMeters) + : room.wagons; + const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1); + let wagons = 0; + let bestCargoTons = 0; + for (let w = 1; w <= ceiling; w += 1) { + const cargoAt = Math.min( + w * perWagon.capacityTons, + room.weightTons - w * perWagon.tareWeightTons, + ); + if (cargoAt > bestCargoTons) { + bestCargoTons = cargoAt; + wagons = w; + } + } + if (wagons < 1) return null; + return { wagons, maxCargoTons: round3(room.weightTons - wagons * perWagon.tareWeightTons) }; +} + export function wagonTypeDimensionsFromEntity(wt: { lengthMeters?: number | string | null; capacityTons?: number | string | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index b72194299..953698ae0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1083,7 +1083,7 @@ export class TrainSchedulingService { { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt, - wagonsRequired: sumWagonsRequired(booking), + wagonsRequired: sumWagonsRequired(booking, wagonPlan), }, manager, ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index e180b482b..19e19dca3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -82,6 +82,29 @@ describe('wagon-plan.util', () => { expect(plan).toHaveLength(2); }); + it('counts a bulk booking\'s wagons from the plan, not a flat 1', () => { + // 700T of sugar on 60T CW3 gondolas = 12 wagons; the stored wagonsRequired + // must carry all of them so gross weight charges 12 tares downstream. + const booking = { + id: 'bulk-700', + reference: 'bulk-700', + freightType: 'BULK', + cargoTotalWeightVgm: 700, + bookingContainers: [], + } as unknown as Booking; + const plan = buildBulkWagonPlan([booking], cw3); + expect(plan).toHaveLength(12); + expect(sumWagonsRequired(booking, plan)).toBe(12); + // Without a plan the pre-plan fallback still applies. + expect(sumWagonsRequired(booking)).toBe(1); + }); + + it('counts container wagons from the plan TEU packing', () => { + const booking = makeContainerBooking('c-plan', [{ quantity: 6, wagonsRequired: 3 }]); + const plan = buildContainerWagonPlan([booking], nw5); + expect(sumWagonsRequired(booking, plan)).toBe(3); + }); + it('6×20ft containers = 3 wagon slots (2 per wagon)', () => { // 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 30a46a099..2af606cdc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -436,7 +436,21 @@ export function expandContainerItems( return items; } -export function sumWagonsRequired(booking: Booking): number { +/** + * Wagons a booking actually occupies. Prefer counting the built wagon plan's + * slots that carry one of the booking's allocations — for BULK that is its + * tonnage spread over real wagons (a 700T booking on 70T wagons rides 10 + * wagons, and downstream gross-weight math charges 10 tares, not 1). Without + * a plan there is no capacity to divide by, so fall back to the pre-plan + * estimates: 1 for bulk, the lines' stored counts for containers. + */ +export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]): number { + const occupiedSlots = (wagonPlan ?? []).filter((slot) => + slot.allocations.some((allocation) => allocation.bookingId === booking.id), + ).length; + if (occupiedSlots > 0) { + return occupiedSlots; + } if (booking.freightType === 'BULK') { return 1; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx index 00b0abd87..b02ebbc8f 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/PriorityTrackingTab.tsx @@ -207,9 +207,9 @@ function RankedCard({ {/* Wagons */} - {/* + {booking.wagons}w - */} + {/* State chip / pay countdown */} @@ -295,9 +295,9 @@ export function PriorityTrackingTab({ data, bookings }: Props) { }, [bookings]); const scoreMax = useMemo(() => maxScore(ranked), [ranked]); - // maxWagons is not on the board DTO (capacity is length/weight-based), so the - // capacity line shows the wagons currently committed rather than a hard cap. - const maxWagons: number | null = null; + // Wagon-slot cap from the board DTO (derived from train length and the + // shortest wagon type); null on legacy rows without a computable cap. + const maxWagons: number | null = data.capacity.maxWagons ?? null; // Split the ranking at the capacity line: cumulative wagons of slot-occupying // bookings (allocated + selected + paid-waiting) up to the train's wagon cap. @@ -431,23 +431,31 @@ export function PriorityTrackingTab({ data, bookings }: Props) { {data.capacity.allocatedWagons} allocated ·{" "} {capUsed} in batch + {maxWagons != null ? ` · ${maxWagons} max` : ""} + {/* Scale against the real wagon cap when the DTO carries one; fall back + to the in-batch total on legacy rows without a computable cap. */} 0 - ? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100) + (maxWagons ?? capUsed) > 0 + ? Math.min( + 100, + (data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100, + ) : 0 } color="edr-green" /> 0 + (maxWagons ?? capUsed) > 0 ? Math.min( 100, - ((capUsed - data.capacity.allocatedWagons) / capUsed) * 100, + ((capUsed - data.capacity.allocatedWagons) / + (maxWagons ?? capUsed)) * + 100, ) : 0 } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index a150978d4..558f15ab4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -20,7 +20,7 @@ import { AlertTriangle, ArrowLeft, ArrowLeftRight, - // Boxes, + Boxes, CalendarDays, CheckCircle2, ClipboardCheck, @@ -348,9 +348,9 @@ const BOOKING_COLUMNS: ColumnDef[] = [ const b = row.original; return ( - {/* + {b.wagons}w - */} + {fmtTons(b.weightTons)} @@ -890,14 +890,14 @@ export default function BatchScheduleDetailPage() { From c53c1868967be380ccc3a140672a82c8b8d2667b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 9 Jul 2026 15:11:13 +0000 Subject: [PATCH 03/75] Approve delivery and notification fix --- .claude/skills/edr-db/SKILL.md | 42 ++++ .claude/skills/edr-db/query.cjs | 88 +++++++++ .claude/skills/standup/SKILL.md | 40 ++++ .claude/skills/verify/SKILL.md | 59 ++++++ CLAUDE_NEW.md | 294 ++++++++++++++++++++++++++++ docs/qa/edr-freight-qa-test-plan.md | 292 +++++++++++++++++++++++++++ 6 files changed, 815 insertions(+) create mode 100644 .claude/skills/edr-db/SKILL.md create mode 100644 .claude/skills/edr-db/query.cjs create mode 100644 .claude/skills/standup/SKILL.md create mode 100644 .claude/skills/verify/SKILL.md create mode 100644 CLAUDE_NEW.md create mode 100644 docs/qa/edr-freight-qa-test-plan.md diff --git a/.claude/skills/edr-db/SKILL.md b/.claude/skills/edr-db/SKILL.md new file mode 100644 index 000000000..47333128d --- /dev/null +++ b/.claude/skills/edr-db/SKILL.md @@ -0,0 +1,42 @@ +--- +name: edr-db +description: Query, EXPLAIN-validate, and inspect the remote EDR freight dev database. Use whenever you need to check data, verify a raw SQL statement before shipping it, list a table's columns, check schema drift, or see which migrations are recorded. psql is NOT installed on this machine — this runner is the sanctioned path. Triggers - "check the db", "query edr_dev", "does column X exist", "validate this SQL", "is migration recorded", "seed check", diagnosing a 400/500 whose cause may be data or schema. +--- + +# EDR dev-DB runner + +One script, runs from anywhere in the repo (resolves `pg` from `apps/edr-freight-api`): + +```bash +node .claude/skills/edr-db/query.cjs "SELECT ... " # run SQL, console.table output +node .claude/skills/edr-db/query.cjs explain "SELECT ..." # EXPLAIN-validate only (no rows touched) +node .claude/skills/edr-db/query.cjs columns # freight.
column list +node .claude/skills/edr-db/query.cjs migrations [like] # public.migrations rows (newest first) +node .claude/skills/edr-db/query.cjs drift
# bare column names, for diffing vs the entity +``` + +Connection comes from `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME`, +defaulting to the shared dev database (`edr_dev`). + +## Rules that go with it + +- **HARD RULE: every raw SQL statement you write into a service must pass + `explain` here before you ship it.** A typo'd column is a runtime 500 the + type-checker cannot catch. +- Never assume a recorded migration applied — check `migrations ` **and** + `columns
` together. Recorded-but-absent = schema drift; fix with a + NEW repair migration (idempotent DDL, no-op `down()`), never by editing the + recorded one. +- Writes to dev data are fine for seeding/diagnosis but keep them idempotent + (`WHERE NOT EXISTS` guards) — watch-mode API instances race `migrationsRun`, + and non-idempotent statements have double-run here before. +- Timestamps for new migrations: must be unique across `src/migrations/` AND + higher than `SELECT max(timestamp) FROM public.migrations`. + +## Diagnosing a pasted 400/500 (the recurring loop) + +1. Find the route: grep the path segment in `apps/edr-freight-api/src/modules/*/**.controller.ts`. +2. Read the service method — list its guard `throw`s. Most "bugs" are a guard + working as designed (handover unsigned, fee unpaid, not PAID, wrong direction). +3. Check the actual DB state for that record with this runner. +4. Only then decide: guard doing its job (fix the UI affordance) vs real defect. diff --git a/.claude/skills/edr-db/query.cjs b/.claude/skills/edr-db/query.cjs new file mode 100644 index 000000000..b4797bd45 --- /dev/null +++ b/.claude/skills/edr-db/query.cjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Dev-DB runner for the EDR freight database. psql is NOT installed on this + * machine; this is the sanctioned way to query, EXPLAIN-validate, and inspect + * the remote dev DB. Resolves `pg` from apps/edr-freight-api so it runs from + * anywhere in the repo. + * + * node .claude/skills/edr-db/query.cjs "SELECT ... " run SQL (console.table) + * node .claude/skills/edr-db/query.cjs explain "SELECT..." EXPLAIN-validate only + * node .claude/skills/edr-db/query.cjs columns
list freight.
columns + * node .claude/skills/edr-db/query.cjs migrations [like] public.migrations rows + * node .claude/skills/edr-db/query.cjs drift
columns vs entity check helper + * + * Connection: DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME env vars, falling + * back to the shared dev database. + */ +const path = require('path'); +const { createRequire } = require('module'); + +const repoRoot = path.resolve(__dirname, '..', '..', '..'); +const apiRequire = createRequire( + path.join(repoRoot, 'apps', 'edr-freight-api', 'package.json'), +); +const { Client } = apiRequire('pg'); + +const cfg = { + host: process.env.DB_HOST ?? '10.18.7.207', + port: parseInt(process.env.DB_PORT ?? '5432', 10), + user: process.env.DB_USER ?? 'postgres', + password: process.env.DB_PASSWORD ?? 'dcba@1234', + database: process.env.DB_NAME ?? 'edr_dev', +}; + +const [, , first, ...rest] = process.argv; + +async function main() { + if (!first) { + console.error('usage: query.cjs "" | explain "" | columns
| migrations [like] | drift
'); + process.exit(2); + } + const c = new Client(cfg); + await c.connect(); + try { + if (first === 'columns') { + const r = await c.query( + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema='freight' AND table_name=$1 + ORDER BY ordinal_position`, + [rest[0]], + ); + console.table(r.rows); + } else if (first === 'migrations') { + const like = rest[0] ? `%${rest[0]}%` : '%'; + const r = await c.query( + `SELECT id, timestamp, name FROM public.migrations + WHERE name ILIKE $1 ORDER BY id DESC LIMIT 40`, + [like], + ); + console.table(r.rows); + } else if (first === 'drift') { + // Quick drift signal: DB columns for the table. Compare by eye against + // the entity's @Column names; a recorded-but-absent column = drift. + const r = await c.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema='freight' AND table_name=$1 ORDER BY column_name`, + [rest[0]], + ); + console.log(r.rows.map((x) => x.column_name).join('\n')); + } else if (first === 'explain') { + await c.query('EXPLAIN ' + rest.join(' ')); + console.log('OK — statement is valid against', cfg.database); + } else { + const sql = [first, ...rest].join(' '); + const started = Date.now(); + const r = await c.query(sql); + if (Array.isArray(r.rows) && r.rows.length) console.table(r.rows); + console.log(`${r.rowCount ?? 0} row(s), ${Date.now() - started}ms`); + } + } finally { + await c.end(); + } +} + +main().catch((e) => { + console.error('FAIL:', e.message); + process.exit(1); +}); diff --git a/.claude/skills/standup/SKILL.md b/.claude/skills/standup/SKILL.md new file mode 100644 index 000000000..272f280e7 --- /dev/null +++ b/.claude/skills/standup/SKILL.md @@ -0,0 +1,40 @@ +--- +name: standup +description: Produce the work report Hagernesh asks for - "what have I done today", "tasks of yesterday and today", daily/period summaries for tickets or timesheets. Builds the answer from git history plus uncommitted work, never from memory alone. +--- + +# Work report (standup / ticket summary) + +Ground every line in git. Do not reconstruct from conversation memory — commits +are the record. + +## Gather + +```bash +# Commits in the window (adjust dates; author matches "Hagernesh") +git log --since="YYYY-MM-DD 00:00" --until="YYYY-MM-DD 00:00" --author=Hagernesh \ + --pretty=format:"%h|%ad|%s" --date=short + +# What each commit actually contains (subjects lie sometimes) +git show --stat --pretty=format:"%s" | head -12 + +# In-flight work = part of "today" even if uncommitted +git status --short +git log origin/dev..HEAD --oneline # branch commits not yet in dev +``` + +## Known pitfalls in this repo + +- **Check subjects against contents.** Commit titles here sometimes mismatch the + diff (e.g. a commit titled "unload export" that actually contained ISO + container validation). Use `git show --stat` before reporting a title as fact. +- A day with no commits usually still has uncommitted/in-flight work — report it + as its own section with per-item status (done / uncommitted / blocked). +- Merge commits from other authors are noise; filter with `--author`. + +## Output format + +One table per day: `# | Task (plain language, not the commit subject verbatim) | +Commit / Status`. Follow with a short "carry-over / blocked" list naming what +blocks each item. Keep it ticket-ready: no jargon that needs the repo open to +decode. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000..a2c715db4 --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,59 @@ +--- +name: verify +description: Project definition-of-done runner for the EDR platform. Use before calling any code change finished, before committing, and whenever asked "is it done / does it work". Runs the targeted checks that actually catch this repo's failure modes - type-check with turbo filters, @edr/types dist rebuild, raw-SQL EXPLAIN validation, migration safety, and honest test reporting. +--- + +# Verify a change (EDR definition of done) + +Run these in order. Report which you ran and what each said — never call +unverified work done. + +## 1. Type-check exactly what you touched + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice --filter=@edr/freight-portal +``` + +Drop filters you didn't touch; whole-repo runs waste minutes. **If you edited +`packages/types`, rebuild it FIRST** — consumers read its `dist/`, not `src/`: + +```bash +pnpm turbo build --filter=@edr/types +``` + +## 2. Validate every raw SQL statement + +Each new/edited `dataSource.query` / `manager.query` string must pass: + +```bash +node .claude/skills/edr-db/query.cjs explain "" +``` + +## 3. Migration checklist (if you added one) + +- Timestamp unique in `src/migrations/` **and** greater than + `node .claude/skills/edr-db/query.cjs "SELECT max(timestamp) FROM public.migrations"`. +- DDL idempotent (`IF NOT EXISTS`, guarded backfills). +- Watch-mode reload does NOT run migrations — apply the SQL to the dev DB + yourself or fully restart the API, then confirm with + `query.cjs columns
`. + +## 4. Tests — honest bar + +`pnpm test` for `@edr/freight-api` is currently red on `dev`, so a green suite +is not the bar. The bar: run the specs nearest what you touched and introduce +**no new failure**. If you touched a service constructor, update its `.spec.ts` +mocks (constructor-arity breaks are this repo's most common test regression). + +## 5. Observe the behaviour + +Compiling is not working. Hit the endpoint, drive the UI flow, or query the +resulting rows. If you genuinely could not observe it, say so explicitly in the +summary — do not imply it was seen working. + +## 6. Before commit + +- Conventional message (`fix(warehouses): …`). Git hooks do NOT run in this + repo (husky shims exist but no user hooks) — nothing will catch it for you. +- Lint the files you touched if in doubt: `pnpm turbo lint --filter=`. +- Do not commit or push unless the user asked. diff --git a/CLAUDE_NEW.md b/CLAUDE_NEW.md new file mode 100644 index 000000000..11075e9f0 --- /dev/null +++ b/CLAUDE_NEW.md @@ -0,0 +1,294 @@ +# EDR Platform — Developer Guide + +> This file is the contract. If something here contradicts the code, the code is the +> truth and this file is a bug — fix it in the same PR. + +## Overview + +Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Freight +Management and Passenger Management applications, a payment microservice, plus shared +types, NestJS utilities, and React component libraries. + +The freight domain is the largest and most active area. Its core flow is: +**booking → receive to warehouse → store → load onto train → dispatch → arrive → unload +→ customer truck (self-haul) or EDR last mile → handover → exit paper → delivered.** +Fees (storage, demurrage, double handling, truck detention) and allocation rules +(warehouse/yard/zone) hang off the warehouse stage. + +## Apps + +| App | Package name | Purpose | Default port | +| ------------------------------ | --------------------------- | -------------------------------------------------- | ------------ | +| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight management | 3001 | +| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customer/portal users | 5173 | +| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight backoffice employees | 5183 | +| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passenger management | 3002 | +| `edr-payment-api` | `@edr/payment-api` | NestJS payment microservice (intents, webhooks) | 3003 | +| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customer/portal users | 5174 | +| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger backoffice employees | 5184 | + +`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. +Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace +packages (see `pnpm-workspace.yaml`). + +`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace +package and is not built, linted, or type-checked. Leave it alone unless asked. + +## Packages + +| Package | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------- | +| `@edr/types` | Shared TypeScript interfaces and enums | +| `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/ui-common` | Shared React components and theme | +| `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | +| `@edr/tsconfig` | Shared TypeScript configurations | +| `@edr/prettier-config` | Shared Prettier configuration | + +**`@edr/types` is consumed as its built `dist/`** (`main: ./dist/index.js`). Editing a +type in `packages/types/src` changes nothing for consumers until you rebuild: + +```bash +pnpm turbo build --filter=@edr/types +``` + +If a type-check fails on a field you just added to `@edr/types`, this is why. + +## Commands + +| Command | Description | +| --------------------------- | ---------------------------------------- | +| `pnpm install` | Install all workspace dependencies | +| `pnpm dev` | Run every app in dev mode | +| `pnpm dev:freight` | Freight API + portal + backoffice | +| `pnpm dev:freight:api` | Freight API only | +| `pnpm dev:freight:portal` | Freight portal only | +| `pnpm dev:freight:backoffice` | Freight backoffice only | +| `pnpm dev:passenger` | Passenger API + web | +| `pnpm dev:payment` | Payment API | +| `pnpm build` | Build every package and app | +| `pnpm test` | Run all tests (turbo) | +| `pnpm lint` | Lint everything | +| `pnpm type-check` | Type-check every package | +| `pnpm format` | Format all files with Prettier | + +Prefer targeted turbo filters over whole-repo runs — they are minutes faster: + +```bash +pnpm turbo type-check --filter=@edr/freight-api --filter=@edr/freight-backoffice +``` + +`apps/edr-freight-api` also carries many `seed:*` scripts (demo bookings, wagons, trains, +gate-pass scenarios). Read the script before running one; several write real rows. + +## Environment & database + +- Postgres is **external**. There is no postgres service in `docker-compose.yaml`, and + no port `5433`/`5434` is published anywhere in the repo. +- Freight API connection comes from `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, + `DB_NAME` (defaults: `localhost:5433`, `edr_freight`). Development points these at a + remote database. +- The connection sits behind a **connection pooler**. Do **not** pass + `extra.options: '-c search_path=…'` — the pooler rejects it with + `08P01 unsupported startup parameter in options: search_path`. `search_path` is applied + per-connection in a pool `connect` handler instead. See + `apps/edr-freight-api/src/config/database.config.ts` before touching connection options. +- Each app owns its own database. **No cross-database joins**; cross-domain data flows + through API calls or message queues. +- `psql` is not installed on the dev machine. To query the database, write a short Node + script using the `pg` client and run it from `apps/edr-freight-api` (where `pg` resolves). + +## Hard rules + +These are non-negotiable. Everything else is a strong default. + +- **pnpm only.** Never run `npm install` or `yarn`. +- **TypeScript strict mode** is on in every package and app. Do not weaken it, and do not + reach for `any` to make an error go away. +- **Never `synchronize: true`.** Not in production, not anywhere. It is currently `false` + in every config and it has already corrupted this database twice (see *Migrations*). + All schema changes go through TypeORM migrations. +- **All entities** use UUID primary keys (`@PrimaryGeneratedColumn('uuid')`). +- **All entities** extend `BaseEntity` from `@edr/api-common` — `createdAt`, `updatedAt`, + `deletedAt` (soft delete). +- **All columns** are `snake_case` in the database (`@Column({ name: 'snake_case' })`); + TypeScript properties are `camelCase`. +- **Controllers contain no business logic.** They validate, delegate, and shape the response. +- **Conventional commits.** `fix(warehouses): …`, `feat(bookings): …`. +- **Do not commit or push unless asked.** Propose the change; let the human decide when it lands. +- **Do not break working behaviour to add new behaviour.** When a fix is risky, say so and + offer the safe version. + +## Architecture + +### NestJS module shape + +`module → controller → service → repository`, with `entities/` and `dto/` alongside. + +### Data access — the real model + +There are two sanctioned ways to read and write, and you must pick the right one: + +1. **Entity CRUD → the custom repository class.** Extends `BaseRepository` from + `@edr/api-common`. Services inject the repository class, never `Repository` directly. +2. **Read projections, queue endpoints, cross-table reports → raw SQL** via + `this.dataSource.query(...)` or `manager.query(...)` inside a transaction. + +Raw SQL is normal here, not a smell — the warehouse and scheduling modules are built on it. +It carries one obligation: + +> **HARD RULE — validate every raw SQL statement against a real database before you ship it.** +> A typo'd column name is a runtime 500 that no type-checker will catch. Run it through +> `EXPLAIN` against the dev database. Column drift is real (see *Migrations*). + +Writes inside a transaction use `manager.getRepository(Entity)`, not the injected repository, +so they join the caller's transaction. + +**Never do slow I/O inside a database transaction.** Queue the work and fan it out after +commit. An SMS awaited inside a transaction once held capacity locks open for the whole +gateway timeout. Any outbound HTTP call must set an explicit `timeout` — axios defaults to +no timeout and will wait forever. + +### Migrations + +Migrations are the most dangerous surface in this repo. Two production-grade incidents have +already come from it. + +- `migrationsRun: true` — **migrations run automatically on API boot**, with + `migrationsTransactionMode: 'each'`. +- Consequences you must design for: + - Running several `nest start --watch` instances races `migrationsRun`. A non-idempotent + data migration can execute twice. Keep one instance. + - A watch-mode hot reload does **not** re-run migrations. If you add a column that new + code reads, apply it to the dev database yourself (idempotently) or fully restart. +- **Give every migration a unique timestamp.** 34 timestamps are currently shared by two or + more migrations. TypeORM orders by timestamp and breaks ties non-deterministically. Before + adding one, check the filename prefix is unused *and* higher than the newest recorded row. +- **Write idempotent DDL**: `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and + backfills guarded by `WHERE col IS NULL`. +- **Never assume a recorded migration actually applied.** `AddGrnNumberToWarehouseInventory` + was recorded in `migrations` while its column was absent — it had been dropped out of band. + TypeORM will never re-run a recorded migration, so the fix is a *new repair migration*. +- **A repair migration's `down()` should be a no-op.** Reverting a repair must not + re-introduce the outage it fixed. + +### Auth + +Auth **is implemented in this repo.** Do not add TODO stubs, and do not write your own. + +- `@CurrentUser()` (`@edr/api-common`) is a real `createParamDecorator`, not a metadata stub. +- Route protection uses `@UseGuards(JwtGuard)` and `@UseGuards(PermissionGuard([...]))`. +- Freight-domain checks use `hasFreightPermission(user, FREIGHT_PERMS..)`. +- Permissions are declared in `apps/edr-freight-api/src/seed/freight-permissions.registry.ts`. + Add a permission there before referencing it. +- IAM has its own migrations, run ahead of freight migrations from the same data source, and + its own CLI scripts (`iam:migration:run`, `iam:seed:run`). + +Ownership checks are separate from permission checks. A staff user passes +`hasFreightPermission`; a customer must additionally pass an ownership assertion such as +`assertCustomerCanAccessBooking`. Do not drop the ownership check because the permission check passed. + +## Frontend conventions + +- The web apps use **Mantine v9**. Its APIs differ from v6/v7 — check the installed version + before copying a snippet. +- `@edr/ui-common` holds shared components and theme; it is imported in ~94 files across the + freight web apps. Prefer it over re-implementing a component. +- **Blob downloads need the async error decoder.** A request with `responseType: 'blob'` + delivers the JSON error body as a `Blob`, so the synchronous `extractErrorMessage` finds no + `.message` and degrades to `"Request failed with status code 400"`. Use + `await extractDownloadErrorMessage(error)` in every PDF/blob catch block. Mutation catches + keep the synchronous version — their bodies are already parsed JSON. +- Server-side guards must be reflected in the UI. If the API will reject the action, the + button should be disabled, hidden, or explain the blocker — not fire and surface a 400. +- Prefer disabling a control with a visible reason over silently hiding it. + +## Notifications + +In-app notifications resolve recipients from the company's **linked portal users**. If a +company has none, `notify()` logs `0 recipients — skipped` and stores nothing, with no error. +SMS and email still send, because they address the company's phone and email directly. Check +this before debugging a "missing notification". + +## PDF generation + +Chromium is not installed in every environment. PDF paths must fall back to the hand-rolled +generators (`styled-pdf.util.ts`, `buildFallbackPdf`, `buildTabularFallbackPdf`) rather than +assume a headless browser exists. + +## Adding a new module to a NestJS app + +1. Create `modules//` with `entities/`, `dto/`, and the four + `.{module,controller,service,repository}.ts` files. +2. The entity extends `BaseEntity` from `@edr/api-common`. +3. The repository extends `BaseRepository` from `@edr/api-common`. +4. The service injects the repository class (not `Repository` directly). +5. The controller uses `@ApiTags()` + `@ApiOperation()` for Swagger, and guards the route. +6. Register the module in the app's `app.module.ts`. + +## Adding a new shared component to `@edr/ui-common` + +1. Create `src/components//.tsx` and `src/components//index.ts`. +2. Export from `src/index.ts`. +3. Component is a functional component with a `ComponentNameProps` interface + (named-exported alongside the default). + +## Definition of done + +A change is done when **all** of these hold. State explicitly which you ran. + +1. **It type-checks.** `pnpm turbo type-check --filter=` passes. + If you edited `packages/types`, you ran `pnpm turbo build --filter=@edr/types` first. +2. **Raw SQL is verified.** Every new or edited SQL statement ran under `EXPLAIN` against the + dev database without error. +3. **Migrations are safe.** Unique timestamp, idempotent DDL, and — if the migration adds + something the new code reads — applied to the dev database, since watch mode will not run it. +4. **No new test failures.** `pnpm test` for `@edr/freight-api` is **currently red on `dev`**, + so a fully green suite is not the bar. Run the specs covering what you touched and confirm + you introduced no new failure. +5. **Lint and format are clean** for the files you touched. Git hooks do **not** run these + automatically (see below), so run them yourself. +6. **The behaviour was actually observed**, not merely compiled — you drove the flow, hit the + endpoint, or ran the query. If you could not, say so plainly. +7. **Report honestly.** If a check was skipped, tests failed, or a fix is unverified, say it in + the summary. Never describe unverified work as done. + +### Hooks do not run + +`commitlint.config.js` and a `lint-staged` config both exist, and husky's shims are installed +at `.husky/_/`. But there are **no user hook scripts** (`.husky/pre-commit`, +`.husky/commit-msg`), so husky's shim exits 0 and **neither lint-staged nor commitlint ever +fire.** Nothing validates your commit message or formats your staged files. Run the checks by +hand; do not assume the hook caught it. + +## Known traps + +| Trap | What happens | What to do | +| --- | --- | --- | +| Schema drift | A recorded migration's column is missing; queries and inserts 500 | Write a new repair migration; never edit the recorded one | +| Duplicate migration timestamps | Non-deterministic ordering; a migration can be skipped | Pick a fresh, higher timestamp | +| `@edr/types` not rebuilt | Consumers can't see your new field | `pnpm turbo build --filter=@edr/types` | +| Slow I/O in a transaction | Locks held for the gateway timeout | Queue it; fan out after commit; always set an HTTP timeout | +| Blob error bodies | Real 400 message replaced by "Request failed with status code 400" | `await extractDownloadErrorMessage(error)` | +| Company with no portal user | In-app notification silently vanishes | Check portal users before debugging | +| Watch-mode reload | New code, old schema → 500 | Apply the migration to the dev DB or restart fully | + +## Project skills + +Reusable workflows live in `.claude/skills/`. Use them instead of re-deriving the steps: + +| Skill | Use for | +| --- | --- | +| `edr-db` | Query / `EXPLAIN`-validate / inspect the remote dev DB (`node .claude/skills/edr-db/query.cjs …`). psql is not installed — this is the sanctioned path. Also carries the 400/500 diagnosis loop. | +| `verify` | The definition-of-done runner: targeted type-check, `@edr/types` rebuild, SQL validation, migration checklist, honest test bar. Run before calling anything finished. | +| `standup` | "What did I do today / this week" reports for tickets, grounded in `git log` — including the check that commit subjects match their contents. | + +## Working style + +- **Verify before asserting.** Read the code or query the database. Do not infer behaviour + from a filename. +- **Investigate, then propose.** For anything risky or wide-reaching, present the plan and the + trade-off before changing files. +- **Small, reviewable commits**, one logical change each, conventional message. +- **Branch from `dev`; PRs target `dev`.** +- When a finding turns out to be wrong, say so and retract it. A rejected finding is a result. diff --git a/docs/qa/edr-freight-qa-test-plan.md b/docs/qa/edr-freight-qa-test-plan.md new file mode 100644 index 000000000..f9102bc18 --- /dev/null +++ b/docs/qa/edr-freight-qa-test-plan.md @@ -0,0 +1,292 @@ +# EDR Freight — Operations QA Test Plan + +End-to-end test flows from booking through warehouse, rail, and delivery — import and export, +with and without first/last mile, self-haul and EDR haulage. Every status, guard, and endpoint +below is taken from the code, not assumed. + +| | | +|---|---| +| **Branch** | `Truckdetantion` | +| **Scope** | Warehouse · Fees · Allocation · First mile · Last mile | +| **Depth** | Tester steps + technical refs | + +--- + +## 00 · Test data setup + +Nothing below passes without this. Set it up once per environment and confirm each line before +opening a single flow. + +- [ ] **Warehouse tree.** At least one `ACTIVE` warehouse with a yard and a zone. Capacities are in **tonnes**, not kg. +- [ ] **Company has a linked portal user.** Critical — in-app notifications resolve recipients from the company's portal users. With none linked, `notify()` logs `0 recipients — skipped` and stores nothing. SMS/email still fire. +- [ ] **Customer saved signature.** Required for Approve delivery; without it the API returns *"Please save your signature before approving delivery"*. +- [ ] **Allocation rules** covering the freight type and trade direction under test (see §08), or accept the capacity-balanced fallback. +- [ ] **Fee rules** — at least one each of `STORAGE_FEE`, `DEMURRAGE_FEE`, `DOUBLE_HANDLING_FEE`, `TRUCK_DETENTION_FEE` (see §07). +- [ ] **Drivers and vehicles** registered; a train schedule with wagons for the route under test. +- [ ] **Booking reaches `PAID`.** Receive-to-warehouse skips any booking that is not PAID. +- [ ] **Container numbers are ISO 6346** — 4 letters + 7 digits, uppercase (`ABCU1234567`). Enforced at booking input and at every reference point. + +> **Direction is derived, not declared.** Receive-to-warehouse computes trade direction from the +> *origin and destination yard countries*, not the booking's stored `trade_direction`. A booking +> whose route says IMPORT will be skipped from an EXPORT receive with *"Booking route is IMPORT, +> not EXPORT"*. Set up yards accordingly. + +--- + +## 01 · Lifecycle reference + +The three state machines a tester needs to read a failure. Anything not listed as an allowed +transition is rejected by `assertTransition`. + +### Warehouse inventory transitions + +| From | Allowed next | Notes | +|---|---|---| +| `UNLOADED` | `STORED`, `READY_FOR_PICKUP` | Import landing state after train unload | +| `RECEIVED` | `STORED`, `READY_FOR_PICKUP` | Export landing state after truck receive | +| `STORED` | `RESERVED`, `READY_FOR_LOADING` | Reserve is retired from the UI; `STORED → READY_FOR_LOADING` is the live path | +| `READY_FOR_LOADING` | `LOADED` | Onto a wagon | +| `LOADED` | `DISPATCHED` | | +| `DISPATCHED` | `UNLOADED_AT_DJIBOUTI_PORT` | Export only, at Djibouti | +| `READY_FOR_PICKUP` | `DELIVERED`, `STORED`, `DISPATCHED` | Import; may be put back into storage | + +### Container item stages + +``` +PENDING → RECEIVED → GRN → ASSIGNED → LOADED → LEFT → DELIVERED +``` + +**ASSIGNED** means the customer picked which containers ride which truck — planning only. +**LOADED** requires the operator to actually load them, and only after the truck has arrived +(`loaded_at` is stamped then). Assignment alone must never show LOADED. + +### First mile & last mile + +| Leg | Statuses, in order | Gate it controls | +|---|---|---| +| First mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT` | Export receive is blocked until `RECEIVED_TO_PORT` | +| Last mile | `PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED` | Truck-detention window: `arrivedAt` (reached destination) → `deliveredAt` (vehicle returned) | + +--- + +## 02 · Export — without first mile + +Customer brings the cargo to the facility themselves. The happy path from a paid booking to cargo +unloaded at Djibouti port with an interchange document. + +| Step | Tester action | Expected result | Technical ref | +|---|---|---|---| +| E1.1 | Create an export booking (route origin ET → destination DJ), pay it. | Booking reaches `PAID`. | Container numbers must be ISO 6346 | +| E1.2 | Export Operations → **Receive for Loading**. Select the booking, capture the truck entrance (plate, driver, weights), pick warehouse/yard/zone. | Inventory created; **GRN issued** as `GRN-EXPORT-YYYYMMDD-XXXXXXXX`. Customer gets a receive SMS. | `POST /warehouse-inventory/receive-bulk` | +| E1.3 | Store the item — auto-allocate, or pick warehouse/yard/zone in the Store modal. | Status `STORED`; note records "allocation rule", "capacity-balanced", or "operator-selected". | Capacity decremented in tonnes | +| E1.4 | Inspect: mark selected items as inspected, outcome **PASSED**. | Export items advance straight to `READY_FOR_LOADING`. | Reserve step is retired | +| E1.5 | Ready To Load tab → load onto the allocated wagon. | Status `LOADED`; a warehouse loading record exists. | Requires an allocated wagon | +| E1.6 | Download the **export marshalling / load list** PDF. | PDF lists the train's wagons, bookings, containers. | train-scheduling controller | +| E1.7 | Dispatch Queue → dispatch. | Status `DISPATCHED`. Customer receives **"Shipment dispatched"** naming origin → destination. | Per booking on the schedule | +| E1.8 | Move the schedule to arrived at the Djibouti-side port. | Train appears in the Djibouti unloading queue. Customer receives **"Shipment arrived"**. | status `ARRIVED` / `ARRIVED_AT_DJIBOUTI` | +| E1.9 | Grant the gate pass for the train, then **Unload at Djibouti**. | Items become `UNLOADED_AT_DJIBOUTI_PORT` and an **interchange document** is generated. | Unload checks `gatepass_granted_at` | +| **E1.G** | Try to unload at Djibouti *before* granting the gate pass. | 🚫 **Blocked.** Items skipped with a gate-pass reason; no interchange document. | See §06 | + +--- + +## 03 · Export — with first mile + +EDR collects the cargo from the customer's premises. Identical to §02 from the store step onward; +the difference is entirely in the gate before receive. + +| Step | Tester action | Expected result | Technical ref | +|---|---|---|---| +| E2.1 | Create the export booking with a **first-mile pickup address** (or a service type that includes first mile). | Booking is flagged `hasFirstMile`. | Derived from address *or* `service_types.includes_first_mile` | +| E2.2 | Create the first-mile request; assign a vehicle and driver. | Driver receives an SMS naming the vehicle, booking, pickup and destination. | First Mile page | +| E2.3 | Walk the leg: `READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT`. | Each transition persists. | | +| E2.4 | Now run **Receive for Loading**. | Booking is received; truck entrance pre-filled from the first-mile vehicle and driver. | Then continue at E1.3 | +| **E2.G1** | Attempt receive with **no first-mile request created**. | 🚫 Skipped: *"First-mile request not created"*. | | +| **E2.G2** | Attempt receive while first-mile status is `IN_TRANSIT`. | 🚫 Skipped: *"First-mile truck has not arrived"*. | Only `RECEIVED_TO_PORT` passes | + +--- + +## 04 · Import — self-haul (customer collects) + +The longest flow, and the one with the most guards. The customer assigns their own trucks, signs a +booking-level handover, and collects. Test this one first — it exercises truck arrival, loading, +weighing, handover, exit paper, and fees. + +``` +train arrives → unload → inspect → ready for pickup → assign truck → truck arrival + → sign handover → load → truck leaving → exit paper → deliver +``` + +| Step | Tester action | Expected result | Technical ref | +|---|---|---|---| +| I1.1 | Import booking (route DJ → ET), paid, **no last-mile address** and a service type that excludes last mile. | Booking is self-haul. | Drives `usesCustomerTruck` | +| I1.2 | Import Operations → **Arrival Queue**. Open the arrived train, assign warehouse/yard/zone per booking, **Auto Unload Arrived Bookings**. | Inventory created at `UNLOADED`. Counter shows `n/total unloaded`. | Train must be `ARRIVED` | +| I1.3 | Inspect the item, outcome **PASSED**. | Item advances to `READY_FOR_PICKUP`. Customer receives **"Assign a truck for pickup"** (in-app + SMS + email). | Fires only when self-haul *and* no truck assigned | +| I1.4 | **Portal:** customer assigns truck(s), entering ISO container numbers per truck. | Containers move to stage `ASSIGNED` and show their planned truck. Booking becomes `TRUCK_ASSIGNED`. | 20ft → max 2/truck; 40ft → 1/truck; trucks ≤ containers | +| I1.5 | Backoffice row menu → **Truck Arrival**. Select the assigned truck, record gate-in time and **tare** weight (tonnes). | Truck stamped arrived. A **SELF_HAUL handover** is generated (booking-level) and the customer is notified to sign, on all three channels. | Truck Arrival disabled until a truck is assigned | +| I1.6 | **Portal:** customer opens the booking → **Approve delivery**. | The handover PDF opens for review; approving applies their saved signature and returns the signed PDF. | Signs all unsigned handovers on the booking | +| I1.7 | Open the container list → select the assigned containers → **Load onto truck** (pick the arrived truck). | Containers move to `LOADED`; `loaded_at` stamped. | Only arrived, not-departed trucks are listed | +| I1.8 | Row menu → **Truck Leaving**. Select the containers on the truck, record gate-out time and **gross** weight. | Net is computed from the selected containers' cargo weight and must equal `gross − tare`. Release document issued. | Weight match enforced client- *and* server-side | +| I1.9 | Generate the **exit paper** for the truck. | PDF lists the truck, driver, and its containers. Containers move to `LEFT`. | Requires signed handover + cleared fees | +| I1.10 | **Deliver** the inventory, recording the receiver name. | Status `DELIVERED`; handovers stamped delivered. | Requires handover signed *and* truck departed | + +> 🚫 **The single most likely bug you will hit.** Exit paper returns `400` when the handover is not +> fully signed, or when a warehouse fee invoice is `ISSUED` / `PARTIALLY_PAID`. In the container +> list, the Exit Paper button turns grey and clicking it **sends the customer a signature request** +> instead of erroring. That is correct behaviour — verify the message, don't file it as a bug. + +--- + +## 05 · Import — EDR last mile + +EDR delivers to the customer's door. No customer truck, no portal Approve delivery, and the +handover is *per delivering truck* — not per booking. This is where truck detention accrues. + +| Step | Tester action | Expected result | Technical ref | +|---|---|---|---| +| I2.1 | Import booking with a **last-mile delivery address** (or service type including last mile). | `hasLastMile` is true; no "assign a truck" notification is sent. | EDR haulage — customer assigns nothing | +| I2.2 | Unload from the arrived train, inspect PASSED. | Item becomes `READY_FOR_PICKUP` and the last-mile leg is accepted automatically. | | +| I2.3 | Last Mile page: assign vehicle + driver. | Leg reaches `READY_TO_TRANSIT`. Row shows Assigned. | Driver notified by SMS | +| I2.4 | **Truck Arrival** from the Last Mile row: gate-in, tare weight. | Weighing saved; the assigned last-mile truck is pre-selected. | Re-opening must show the saved details | +| I2.5 | **Truck Leaving**: gate-out, gross weight. | Release document issued; leg moves to `IN_TRANSIT`. `arrivedAt` starts the detention clock. | Detention window opens | +| I2.6 | Deliver at the customer's door, recording the receiver name. | Item `DELIVERED`. An **EDR_LAST_MILE handover is generated per delivering truck**, resolved from the container's allocated vehicle. | Not booking-level | +| I2.7 | Return the vehicle → mark the leg `DELIVERED`. | `deliveredAt` stamped; detention clock stops. | | +| I2.8 | Preview, then generate the **truck detention invoice**. | Charged per truck per day beyond the grace hours, at the matching tier. See §07. | `POST /last-mile/:id/generate-truck-detention-invoice` | +| **I2.G** | Open the booking in the portal. | 🚫 **Approve delivery must NOT appear.** The portal flag counts only `SELF_HAUL` handovers; EDR handovers are signed by the receiver at the door. | Regression check | + +--- + +## 06 · Marshalling, gate pass, interchange + +Documents are generated, not uploaded. Each has a precondition; test the precondition, not just the PDF. + +| Document | When | Precondition | Verify | +|---|---|---|---| +| **GRN** | On receive to warehouse | Booking `PAID`; route direction matches; export needs a truck entrance | Number format `GRN---<8>`; PDF opens; customer SMS sent | +| **Import load list / marshalling** | Import train, before unload | Schedule has bookings assigned | Summary endpoint and printable PDF agree; portrait/landscape both render | +| **Export marshalling / load list** | Export train, after loading | Items `LOADED` onto wagons | Wagon, booking, container rows are complete | +| **Gate pass** | Djibouti-side operations | Granted per schedule | `gatepass_granted_at` is set; **export Djibouti unload reads this same field** | +| **Interchange document** | Automatically, after a successful Djibouti unload | At least one item unloaded | Document number returned in the unload response; visible in Interchange Documents | +| **Handover** | Self-haul: on truck arrival. EDR: at delivery. | See §04 / §05 | Self-haul is booking-level, one per booking; EDR is one per delivering truck | +| **Exit paper / release doc** | Truck leaving | Handover fully signed *and* no unpaid warehouse fee | Weights on the paper match the gate weighing | + +> ⚠️ **Cross-module quirk worth confirming with the team.** The *export* Djibouti unload checks the +> gate-pass flag stored on the *import* Djibouti operations record +> (`import_djibouti_operations.gatepass_granted_at`). It works, but it is surprising. If an export +> unload silently skips every item, check that field first. + +--- + +## 07 · Fee rules + +Four rule types. Two are day-based with free days and tiers; double handling is a flat rate +multiplied by a basis; truck detention is hour-graced and vehicle-scoped. + +| Rule type | Charged on | Key fields | Test cases | +|---|---|---|---| +| `STORAGE_FEE` | Days in storage | free days, `tiers` | Within free days → zero. One day past → tier 1. Cross a tier boundary → correct tier rate. | +| `DEMURRAGE_FEE` | Days beyond free time | free days, `tiers` | Same boundary tests. Confirm it blocks exit paper and delivery while `ISSUED`. | +| `DOUBLE_HANDLING_FEE` | Flat rate × quantity | `basis`: `PER_CONTAINER` \| `PER_TON` \| `PER_ITEM` | Container booking → PER_CONTAINER uses container count. Bulk → PER_TON uses tonnage. Break-bulk machinery → PER_ITEM uses item count. **Import only.** Free days and tiers must not apply. | +| `TRUCK_DETENTION_FEE` | Per truck, per day | `free_hours` grace, `tiers`, vehicle type scope | Return inside the grace window → zero. Just past grace → day 1 at tier 1. Multi-day → tier escalation. A vehicle type outside the rule's scope → no charge. **Import only.** | + +### Fee behaviour to verify on every rule + +- [ ] **Preview before invoice.** The preview amount must equal the issued invoice total. +- [ ] **Notification on issue.** Issuing a warehouse fee invoice sends the customer an in-app `INVOICE_ISSUED` notification *and* an SMS, deep-linked to pay. +- [ ] **Clearance gate.** While a warehouse-source invoice is `ISSUED` or `PARTIALLY_PAID`, exit paper, terminal release, and Approve delivery are all blocked. +- [ ] **Payable-but-uninvoiced.** If fees are payable and no invoice exists yet, release is still blocked with *"Generate and fully pay…"*. Confirm the operator can generate it from that state. +- [ ] **Fully paid** → release proceeds; a receipt PDF is available. +- [ ] **Edit a rule** (rate, free days, tiers, grace hours) and confirm the next preview reflects it. + +--- + +## 08 · Allocation rules + +Where an item is stored is decided by the first matching rule, in priority order. Test the +precedence, not just one rule. + +| Match criteria (any may be null = wildcard) | Targets | +|---|---| +| `freight_type`, `trade_direction`, `cargo_type_code`, `container_status`, `requires_inspection`, ordered by `priority` | `target_facility_code`, `target_warehouse_code`, `target_yard_code` (required), `target_zone_code`, `storage_type` | + +### Precedence, highest first + +| # | Source of the location | How to trigger | Note recorded | +|---|---|---|---| +| 1 | **Operator selection** | Store modal → pick warehouse + yard + zone | "Stored at operator-selected location" | +| 2 | **Allocation rule** | Leave the Store modal blank; a matching rule exists | "Stored by allocation rule ``" | +| 3 | **Capacity-balanced fallback** | Leave blank; no rule matches | "Stored by capacity-balanced allocation" | + +- [ ] Two matching rules → the **lower priority number wins**. +- [ ] Yard dropdowns are filtered by **freight type** — container bookings offer container yards only. +- [ ] Only `ACTIVE` warehouses, yards and zones are selectable. +- [ ] Storing beyond a zone's capacity is rejected; capacities are compared in **tonnes**. +- [ ] **Move** an item to another warehouse/yard/zone → capacity released at source, taken at destination. +- [ ] Edit a rule, an existing warehouse, a yard, and a zone — all four must be editable. + +--- + +## 09 · Negative & guard cases + +Every row here is intended behaviour. The test passes when the action is **refused** with the +stated message. Anything that succeeds is the bug. + +| Area | Attempt | Expected refusal | +|---|---|---| +| Booking | Enter a container number that is not 4 letters + 7 digits (e.g. `MSKU10105185`, `3456789`). | *"Enter a valid ISO container number"*. Lowercase is auto-uppercased; input capped at 11 characters. | +| Booking | Enter the **same container number twice** in one shipment. | *"Duplicate container number in this shipment."* | +| Receive | Receive a booking that is not `PAID`. | Skipped: *"Booking not PAID"*. | +| Receive | Receive the same booking twice. | Skipped: *"Already received"*. | +| Receive | Export receive with no truck entrance captured. | Rejected before any inventory is created. | +| Truck assign | Put **two 40ft containers** on one truck. | *"A 40ft container fills the truck — assign only 1 container to this truck"*. | +| Truck assign | Put **three containers** on one truck. | *"A truck carries at most 2 containers"*. | +| Truck assign | Assign **more trucks than the booking has containers**. | *"Cannot assign more trucks than containers…"*. | +| Truck assign | Assign a container from another booking, or one already on another truck. | *"…is not one of this booking's containers"* / *"…already loaded onto another truck"*. | +| Truck assign | Edit a truck **after it has arrived**. | Refused — edits are allowed only until arrival. | +| Loading | **Load containers onto a truck that has not arrived.** | *"Record the truck arrival before loading…"*. The truck picker lists only arrived, not-departed trucks. | +| Loading | Load onto a truck that has already departed. | *"This truck has already left — its load is locked"*. | +| Stages | Customer assigns containers to a truck, then check the container list. | Stage is `ASSIGNED`, **never** `LOADED`. Exit Paper is not offered. | +| Truck leaving | Enter a gross weight where `gross − tare` ≠ the selected containers' cargo weight. | *"Weight mismatch…"*. Exit paper and gate clearance blocked, client and server. | +| Truck leaving | Save leaving with **no containers selected**. | *"Select the containers loaded on this truck"*. | +| Exit paper | Generate before the handover is signed. | *"Handover must be signed…"*. In the container list the button is grey and instead **sends the customer a signature request**. | +| Exit paper | Generate with an `ISSUED` demurrage/storage invoice. | *"…must be fully paid before terminal release"*. | +| Approve delivery | Approve without a saved signature. | *"Please save your signature…"* and the portal routes to the signature page. | +| Approve delivery | Approve before warehouse inspection has passed. | *"Delivery can be approved after warehouse inspection has passed"*. | +| Approve delivery | Approve when a truck is assigned but has not arrived. | *"Customer truck arrival must be recorded before delivery approval"*. | +| Deliver | Deliver before a release order was issued, or before the self-haul truck has left. | *"A release order must be issued…"* / *"Deliver is available only after the customer truck has left"*. | +| Djibouti unload | Unload an export train with no gate pass granted. | Items skipped; no interchange document generated. | +| Warehouse | Store into an `INACTIVE` warehouse/yard/zone, or beyond capacity. | Not selectable / *"No active warehouse yard/zone is available"* / capacity error. | + +--- + +## 10 · Notifications + +All customer notifications land in the same portal inbox. Verify the message, the deep link, and — +where noted — the SMS and email. + +| Notification | Fires when | Channels | Deep link | +|---|---|---|---| +| Shipment dispatched | Train schedule dispatched, per booking | In-app, SMS, email | Booking | +| Shipment arrived | Train schedule arrived, per booking | In-app, SMS, email | Booking | +| Assign a truck for pickup | Export: on warehouse receive. Import: on inspection pass → ready for pickup. Only if self-haul *and* no truck assigned. | In-app, SMS, email | Booking → assign truck | +| Handover — signature needed | Self-haul truck arrives (handover generated), and re-sent when an operator requests a signature from the Exit Paper button | In-app, SMS, email | Booking → approve delivery | +| Warehouse fee due | Storage / demurrage invoice issued | In-app, SMS | Booking → pay | +| Wagon allocated / payment window | Scheduling | In-app | Booking | + +> 🚫 **Do not chase a missing in-app notification before checking this.** Recipients are resolved +> from the company's linked portal users. If a company has none, `notify()` logs *"0 recipients — +> skipped"* and stores nothing — the notification simply never appears, with no error. SMS and +> email still go out, because they address the company's phone and email directly. On a fresh +> environment this is the usual explanation. + +--- + +## 11 · Known open issues + +Do not raise duplicates for these. Each is already identified. + +| Status | Issue | Impact on testing | +|---|---|---| +| 🔴 **Open** | **Export receive returns 500** on the deployed environment (`POST /warehouse-inventory/receive-bulk`). | Blocks flows E1 and E2 at step .2. Awaiting the response body / server log to diagnose. Likely schema drift, not the SQL. | +| 🟠 **Fix pending deploy** | **Export receive was extremely slow.** The owner SMS was awaited inside the DB transaction, and the SMS client had no HTTP timeout. | Fixed on branch: SMS now has a timeout, and notifications are sent after commit. Re-test receive latency once deployed. | +| 🟠 **Data** | **Drivers table has no unique constraints** on licence number, email, or phone, despite the entity declaring them unique. | Duplicate drivers can be created. Do not rely on uniqueness in test assertions. | +| 🔵 **Behaviour** | **Handover generated before truck arrival.** If an operator triggers a signature request from the Exit Paper button while a truck is assigned but not arrived, Approve delivery refuses with *"truck arrival must be recorded"*. | Only reachable off the normal path. Follow flow I1 in order and it will not occur. | From 001babbd2d522c01168fa4fa74ae77045562119b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 9 Jul 2026 13:40:50 +0000 Subject: [PATCH 04/75] fix(warehouses): repair missing warehouse_inventory.grn_number column AddGrnNumberToWarehouseInventory1828000000000 is recorded in the migrations table but the column is absent - it was added, then dropped out-of-band. Because TypeORM has the original recorded it will never re-run, so every GRN read/write fails with "column grn_number does not exist": - bulkReceive() INSERT names grn_number (receive to warehouse) - importQueueByStatuses() Unloaded + Dispatch queues - exportInventoryByStatus() Received / Ready-To-Load / Loaded tabs - grnDocument() GRN PDF Re-adds the column, backfills from the "GRN Number:" receive note, recreates the partial index. Idempotent, and down() is a deliberate no-op so reverting the repair cannot re-introduce the outage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2090000000000-RepairGrnNumberColumn.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts diff --git a/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts new file mode 100644 index 000000000..aa2f998a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2090000000000-RepairGrnNumberColumn.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Repairs `freight.warehouse_inventory.grn_number`. + * + * AddGrnNumberToWarehouseInventory1828000000000 is recorded in `migrations` but + * the column is absent on at least one environment - it was added, then dropped + * out-of-band (a stray `synchronize: true`, same class of damage that + * RepairSynchronizeDrift1870000000000 already had to undo). Because TypeORM has + * the original recorded, it will never re-run it. + * + * Without the column, everything that reads or writes a GRN fails with + * `column ... grn_number does not exist`: + * - bulkReceive() -> INSERT names grn_number (receive to warehouse) + * - importQueueByStatuses() -> Unloaded + Dispatch queues + * - exportInventoryByStatus() -> Received / Ready-To-Load / Loaded tabs + * - grnDocument() -> GRN PDF + * + * Idempotent: a no-op on environments where the column survived. + */ +export class RepairGrnNumberColumn2090000000000 implements MigrationInterface { + name = 'RepairGrnNumberColumn2090000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL + `); + + // Recover the GRN for rows received before the column existed: it was also + // written into the receive note as "GRN Number: ". + await queryRunner.query(` + UPDATE freight.warehouse_inventory + SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)') + WHERE grn_number IS NULL + AND notes IS NOT NULL + AND notes ~ 'GRN Number: ' + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number + ON freight.warehouse_inventory(grn_number) + WHERE grn_number IS NOT NULL + `); + } + + /** + * Deliberately a no-op. Dropping the column is what broke these environments + * in the first place, and the original 1828 migration already owns its own + * down(). Reverting this repair must not re-introduce the outage. + */ + public async down(): Promise { + // intentionally empty + } +} From ca774267cd08411921a1ec3af81318a23519d8ac Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 9 Jul 2026 13:41:57 +0000 Subject: [PATCH 05/75] fix(warehouses): Store no longer strands an import item in STORED READY_FOR_PICKUP allows STORED (park an import item back into storage), but STORED allowed only RESERVED / READY_FOR_LOADING - so readyForPickup() hit assertTransition(STORED, READY_FOR_PICKUP) and threw. The item could never return to pickup, and getNextInventoryAction returned null for an import STORED item, leaving the row with no action at all. - allow STORED -> READY_FOR_PICKUP - an inspected import STORED item now advances to ready-for-pickup readyForPickup() still rejects non-IMPORT inventory, so the new edge cannot be reached from the export flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/entities/warehouse-inventory.entity.ts | 4 +++- apps/edr-freight-web/backoffice/src/types/warehouse.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index a54f40973..b2c4ca3c4 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -36,7 +36,9 @@ export const WAREHOUSE_INVENTORY_TRANSITIONS: Record Date: Thu, 9 Jul 2026 13:44:11 +0000 Subject: [PATCH 06/75] fix(warehouses): surface the real reason when a PDF download fails Blob downloads set responseType: 'blob', so axios delivers the JSON error body as a Blob. extractErrorMessage() reads `.message` off it, finds nothing, and falls back to "Request failed with status code 400" - hiding every real reason ("Handover must be signed...", "...must be fully paid before terminal release"). Only ContainerItemsModal used the async Blob decoder. Switch the remaining nine blob-download catches to extractDownloadErrorMessage(): InventoryWorkbench release paper, handover WarehouseInventoryTable GRN ReceiveInventoryModal GRN (x2), handover, exit paper FeePreviewModal release paper TruckDispatchModal truck exit paper Mutation-error catches are untouched - their bodies are already parsed JSON. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/warehouses/FeePreviewModal.tsx | 4 ++-- .../src/components/warehouses/InventoryWorkbench.tsx | 6 +++--- .../components/warehouses/ReceiveInventoryModal.tsx | 10 +++++----- .../src/components/warehouses/TruckDispatchModal.tsx | 4 ++-- .../components/warehouses/WarehouseInventoryTable.tsx | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 78e09a7e0..5eeee7d13 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; import { warehouseService } from '@/services/warehouse.service'; -import { extractErrorMessage } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse'; import { openPdfBlob } from './pdf'; @@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa pdfWindow?.close(); toast({ title: 'Gate clearance recorded', - description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`, + description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`, }); } onClose(); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx index b8bb43086..c4f1f5cec 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryWorkbench.tsx @@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal'; import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { WarehouseInventoryTable } from './WarehouseInventoryTable'; -import { extractErrorMessage } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { openPdfBlob } from './pdf'; interface InventoryWorkbenchProps { @@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo toast({ variant: 'destructive', title: 'Release paper preview failed', - description: extractErrorMessage(error), + description: await extractDownloadErrorMessage(error), }); } finally { setBusyId(null); @@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo toast({ variant: 'destructive', title: 'Handover document failed', - description: extractErrorMessage(error), + description: await extractDownloadErrorMessage(error), }); } finally { setBusyId(null); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 3b4be03db..dd04cd3ff 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; -import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; import { openPdfBlob } from './pdf'; import '@/components/overview/overview.css'; @@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); } finally { setLoading(false); } @@ -900,7 +900,7 @@ function EligibleTab({ toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); } } setSelected(new Set()); @@ -2282,7 +2282,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) }); } finally { setBusyId(null); } @@ -2296,7 +2296,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) }); } finally { setBusyId(null); } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx index 73e37c1ec..f26469ac7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/TruckDispatchModal.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import { useToast } from '@/hooks/use-toast'; import { warehouseService } from '@/services/warehouse.service'; -import { extractErrorMessage } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage } from './options'; import { openPdfBlob } from './pdf'; interface TruckDispatchModalProps { @@ -56,7 +56,7 @@ export function TruckDispatchModal({ opened, onClose, bookingId, bookingReferenc const res = await warehouseService.downloadTruckExitPaper(assignmentId); openPdfBlob(res.data, `exit-${plate}.pdf`); } catch (e) { - toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) }); + toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) }); } }; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 871780bbf..63292b0af 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -10,7 +10,7 @@ import { type WarehouseInventoryItem, } from '@/types/warehouse'; import { InventoryStatusBadge } from './badges'; -import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options'; +import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options'; import { openPdfBlob } from './pdf'; interface WarehouseInventoryTableProps { @@ -78,7 +78,7 @@ function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) { toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' }); } catch (error) { pdfWindow?.close(); - toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) }); + toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) }); } finally { setLoading(false); } From 7ab5e9edd5125ecbb6f91332a85ead656f9bec4b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 9 Jul 2026 15:06:24 +0000 Subject: [PATCH 07/75] feat(warehouses): handover-driven approve delivery, weigh-skip, 5-min sign reminders Approve delivery now tracks the handover lifecycle exactly: the button appears (detail page + portal dashboard) the moment a handover is generated and disappears when the customer signs. The bookings list attaches the handoverAwaitingSignature flag via one batched query per page; status heuristics (COMPLETED / TRUCK_ASSIGNED+arrived) are gone. - generate the arrival handover for ANY self-haul truck: portal-assigned OR walk-in registered at the gate (isSelfHaulBooking: assigned_at set, or no EDR last-mile leg). Same rule now guards the exit paper. - remind every 5 minutes (in-app + SMS + email) until the handover is signed (@Cron in HandoverService; one reminder per booking per tick) - Truck Leaving no longer opens blank: the import queue mapper now carries inv.notes, so the saved arrival renders read-only with only gate-out time and gross weight editable - container bookings get "Weigh truck? Yes/No": No skips tare/gross and the container weight match (weighingSkipped on ReleaseOrderDto, decision made at arrival sticks for the exit via the Weighing: SKIPPED note). Bulk always weighs, unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/modules/bookings/bookings.service.ts | 28 +++++++- .../warehouses/dto/release-order.dto.ts | 11 ++- .../modules/warehouses/handover.service.ts | 32 +++++++++ .../warehouses/warehouse-inventory.service.ts | 71 +++++++++++++------ .../warehouses/ReceiveInventoryModal.tsx | 3 + .../warehouses/ReleaseOrderModal.tsx | 55 ++++++++++---- .../backoffice/src/types/warehouse.ts | 2 + .../MyPortalPage/components/BookingRow.tsx | 4 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 10 +-- 9 files changed, 174 insertions(+), 42 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 48a29988a..c804b2579 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1125,6 +1125,30 @@ export class BookingsService { ); } + /** + * Batched version of the findById flag: marks each page item whose booking + * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal + * dashboard) can show "Approve delivery" for exactly the generated→signed + * window. One query for the whole page. + */ + private async attachHandoverFlags(bookings: Booking[]): Promise { + const ids = bookings.map((b) => b.id); + if (!ids.length) return; + const rows: Array<{ bookingId: string }> = await this.dataSource.query( + `SELECT DISTINCT booking_id AS "bookingId" + FROM freight.booking_handovers + WHERE booking_id = ANY($1::uuid[]) + AND signed_at IS NULL AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL'`, + [ids], + ); + const pending = new Set(rows.map((r) => r.bookingId)); + for (const b of bookings) { + (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature = + pending.has(b.id); + } + } + async findAll( filter: FilterBookingDto, forceCompanyId?: string, @@ -1135,7 +1159,7 @@ export class BookingsService { const statusFilter = this.parseStatusFilter(filter); const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter); - return this.bookingsRepository.findAllPaginated({ + const result = await this.bookingsRepository.findAllPaginated({ page, pageSize, ...statusFilter, @@ -1162,6 +1186,8 @@ export class BookingsService { sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); + await this.attachHandoverFlags(result.items ?? []); + return result; } /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */ diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts index 681b30284..17b75b424 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { IsBoolean, IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator'; /** Records a DO / release order being sent to the customer for import pickup. */ export class ReleaseOrderDto { @@ -90,4 +90,13 @@ export class ReleaseOrderDto { @IsOptional() @IsDateString() gateOutTime?: string; + + @ApiPropertyOptional({ + description: + 'Container bookings only: the operator chose not to weigh this truck. ' + + 'Tare/gross become optional and the container weight match is skipped. Bulk always weighs.', + }) + @IsOptional() + @IsBoolean() + weighingSkipped?: boolean; } diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 5df15910e..48ab3ac48 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, IsNull } from 'typeorm'; @@ -150,6 +151,37 @@ export class HandoverService { ); } + /** + * Reminder loop: until a self-haul handover is signed, re-send the sign + * notification (in-app + SMS + email) every 5 minutes. One reminder per + * booking per tick, newest unsigned handover's reference. Stops the moment + * signForBooking() stamps signed_at. + * + * NB: runs in every API instance — keep a single instance in dev or the + * customer is reminded once per instance per tick. + */ + @Cron(CronExpression.EVERY_5_MINUTES, { name: 'handover-sign-reminder' }) + async remindUnsignedHandovers(): Promise { + try { + const rows: Array<{ bookingId: string; reference: string }> = await this.dataSource.query( + `SELECT DISTINCT ON (booking_id) + booking_id AS "bookingId", reference + FROM freight.booking_handovers + WHERE signed_at IS NULL + AND deleted_at IS NULL + AND mile_type = 'SELF_HAUL' + ORDER BY booking_id, generated_at DESC`, + ); + if (!rows.length) return; + this.logger.log(`Handover sign reminder: ${rows.length} booking(s) still unsigned`); + for (const row of rows) { + await this.notifySignNeeded(row.bookingId, row.reference); + } + } catch (err) { + this.logger.warn(`Handover sign reminder tick failed: ${(err as Error).message}`); + } + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking(bookingId: string, userId?: string | null): Promise { await this.dataSource diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 06e84d455..2b98856d0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2357,6 +2357,27 @@ export class WarehouseInventoryService { }); } + /** + * Self-haul = the customer's own truck collects the goods: either a truck + * assigned via the portal (customer_truck_assigned_at), or a walk-in truck + * registered at the gate on a booking with no EDR last-mile leg. EDR + * last-mile bookings are never self-haul. + */ + private async isSelfHaulBooking(bookingId: string, manager?: EntityManager): Promise { + const runner = manager ?? this.dataSource; + const [row]: Array<{ ok: number }> = await runner.query( + `SELECT 1 AS ok + FROM freight.bookings b + LEFT JOIN freight.service_types st ON st.id = b.service_type_id + WHERE b.id = $1 AND b.deleted_at IS NULL + AND (b.customer_truck_assigned_at IS NOT NULL + OR (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NULL + AND COALESCE(st.includes_last_mile, false) = false))`, + [bookingId], + ); + return Boolean(row); + } + /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */ async release(id: string, dto: ReleaseOrderDto): Promise { const item = await this.findById(id); @@ -2366,19 +2387,17 @@ export class WarehouseInventoryService { ); } - const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); + // Leaving = gate-out captured, with either a weighed gross or an explicit + // container weighing skip (bulk always weighs). + const isTruckLeaving = + Boolean(dto.gateOutTime) && (dto.grossWeight !== undefined || dto.weighingSkipped === true); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); if (item.bookingId) { - const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = - await this.dataSource.query( - `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, - [item.bookingId], - ); - const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + // Self-haul = customer collects: a truck assigned via the portal, OR a + // walk-in truck registered at the gate on a booking with no EDR last mile. + const usesCustomerTruck = await this.isSelfHaulBooking(item.bookingId); // Self-haul: the handover must be signed before the exit paper is issued. // Prefer the structured handover record; fall back to the legacy note. const handoverSigned = @@ -2392,7 +2411,8 @@ export class WarehouseInventoryService { // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. - if (dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { + // Skipped when the operator chose not to weigh (containers only). + if (!dto.weighingSkipped && dto.containerNumber && dto.grossWeight != null && dto.tareWeight != null) { const selected = dto.containerNumber .split(/[,;\n]+/) .map((n) => n.trim()) @@ -2462,13 +2482,10 @@ export class WarehouseInventoryService { [item.bookingId], ); // Self-haul: generate the per-booking handover on first truck arrival - // (idempotent). It must be signed before the truck leaves. - const [selfHaul]: Array<{ ok: number }> = await manager.query( - `SELECT 1 AS ok FROM freight.bookings - WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`, - [item.bookingId], - ); - if (selfHaul) { + // (idempotent) and notify the customer to sign it. Covers BOTH portal- + // assigned trucks and walk-in trucks registered manually at the gate + // (no portal assignment, no EDR last mile). Must be signed before leaving. + if (await this.isSelfHaulBooking(item.bookingId, manager)) { await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); } } @@ -4453,14 +4470,17 @@ export class WarehouseInventoryService { if (!dto.driverName?.trim()) { throw new BadRequestException('Driver name is required for exit inspection'); } - if (dto.tareWeight === undefined) { + // Container bookings may skip the weighbridge entirely (weighingSkipped); + // bulk always weighs. + const weighingSkipped = dto.weighingSkipped === true; + if (dto.tareWeight === undefined && !weighingSkipped) { throw new BadRequestException('Tare weight is required for truck arrival'); } - const tareWeight = Number(dto.tareWeight); + const tareWeight = dto.tareWeight === undefined ? null : Number(dto.tareWeight); const grossWeight = dto.grossWeight === undefined ? null : Number(dto.grossWeight); const computedNetWeight = - grossWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); + grossWeight == null || tareWeight == null ? null : Number((grossWeight - tareWeight).toFixed(3)); const submittedNetWeight = dto.netWeight === undefined || computedNetWeight == null ? computedNetWeight : Number(dto.netWeight); @@ -4472,7 +4492,11 @@ export class WarehouseInventoryService { throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.'); } } - if ((dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && grossWeight == null) { + if ( + !weighingSkipped && + (dto.grossWeight !== undefined || dto.gateOutTime || dto.netWeight !== undefined) && + grossWeight == null + ) { throw new BadRequestException('Gross weight is required for truck exit'); } @@ -4488,7 +4512,8 @@ export class WarehouseInventoryService { dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null, dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null, dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null, - `Tare Weight: ${tareWeight} t`, + weighingSkipped ? 'Weighing: SKIPPED' : null, + tareWeight == null ? null : `Tare Weight: ${tareWeight} t`, grossWeight == null ? null : `Gross Weight: ${grossWeight} t`, computedNetWeight == null ? null : `Net Weight: ${computedNetWeight} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, @@ -4512,6 +4537,8 @@ export class WarehouseInventoryService { containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber, gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime, tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight, + // The weigh/skip decision is made at arrival and sticks for the exit. + weighingSkipped: dto.weighingSkipped || /^Weighing:\s*SKIPPED/im.test(inspection) || undefined, }; } diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index dd04cd3ff..d2540751e 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -2245,6 +2245,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { handoverDocumentReference: row.handoverDocumentReference, handoverDocumentDate: row.handoverDocumentDate, deliveredAt: row.deliveredAt, + // Carries the saved [Exit Inspection] block so Truck Leaving opens with the + // arrival details (plate, driver, tare, gate-in) read-only instead of blank. + notes: row.notes, booking: row.bookingId ? { id: row.bookingId, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index 5cca0d3b2..d986e2aba 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; +import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Info, Scale } from 'lucide-react'; import { useMutation, useQuery } from '@tanstack/react-query'; @@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => { grossWeight: lineNumber(note, 'Gross Weight'), netWeight: lineNumber(note, 'Net Weight'), gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')), + weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''), }; }; @@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const [containerNumbers, setContainerNumbers] = useState(['']); const [gateInTime, setGateInTime] = useState(''); const [tareWeight, setTareWeight] = useState(''); + // Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs. + const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes'); const [grossWeight, setGrossWeight] = useState(''); const [netWeight, setNetWeight] = useState(''); const [gateOutTime, setGateOutTime] = useState(''); @@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber)); setGateInTime(inspection.gateInTime); setTareWeight(inspection.tareWeight); + setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes'); setGrossWeight(inspection.grossWeight); setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight)); setGateOutTime(inspection.gateOutTime); @@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea }, [opened, item, truckPrefill]); const savedInspection = parseInspectionNote(item?.notes); - const isExitStep = savedInspection.tareWeight !== ''; + const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped; const isEntranceLocked = isExitStep; const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); @@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea .reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0) .toFixed(3), ); - const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0; + // Skip is only offered for container bookings; bulk always weighs. + const skipWeighing = hasContainerWeights && weighTruck === 'no'; + const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing; const systemNetWeight = useContainerNet ? selectedCargoWeight @@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const computedNetWeight = tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; const weightMismatch = + !skipWeighing && computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001; const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing'; @@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea toast({ variant: 'destructive', title: 'Truck plate and driver name are required' }); return; } - if (!gateInTime || tareWeight === '') { - toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' }); + if (!gateInTime || (!skipWeighing && tareWeight === '')) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required', + }); return; } - if (isExitStep && (!gateOutTime || grossWeight === '')) { - toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' }); + if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) { + toast({ + variant: 'destructive', + title: skipWeighing ? 'Gate out time is required' : 'Gate out time and gross weight are required', + }); return; } if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) { toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' }); return; } - if (isExitStep && systemNetWeight === '') { + if (isExitStep && !skipWeighing && systemNetWeight === '') { toast({ variant: 'destructive', title: 'System recorded net weight is missing' }); return; } @@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea truckType: truckType.trim() || undefined, containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined, gateInTime: toIsoDateTime(gateInTime), - tareWeight: Number(tareWeight), - grossWeight: grossWeight === '' ? undefined : Number(grossWeight), - netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, + weighingSkipped: skipWeighing || undefined, + tareWeight: skipWeighing ? undefined : Number(tareWeight), + grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight), + netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined, gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined, }, }); @@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea )} setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} /> + {hasContainerWeights && ( + + Weigh truck? + setWeighTruck((v as 'yes' | 'no') ?? 'yes')} + disabled={isEntranceLocked} + /> + {skipWeighing && ( + Weighbridge skipped — container passes without tare/gross. + )} + + )} - setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} /> - setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} /> + setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} /> + setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} /> Date: Thu, 9 Jul 2026 15:14:44 +0000 Subject: [PATCH 08/75] fix(warehouses): retire per-booking Load and Dispatch buttons Wagon loading happens in the train flow and dispatch at the train level, which already advances inventory - the per-row buttons duplicated that and confused operators. - WarehouseInventoryTable (import dispatch queue + warehouse pages): suppress the 'load'/'dispatch' next-action buttons and drop the extra per-row Dispatch on READY_FOR_PICKUP rows (Store stays) - Export Dispatch Queue: drop the per-row Dispatch button and its Actions column; the bulk Dispatch All / Dispatch Selected controls remain Co-Authored-By: Claude Opus 4.8 (1M context) --- .../warehouses/ReceiveInventoryModal.tsx | 14 -------- .../warehouses/WarehouseInventoryTable.tsx | 36 ++++++++----------- 2 files changed, 15 insertions(+), 35 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index d2540751e..a1b91decb 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -1697,7 +1697,6 @@ function LoadedExportTab({ Weight Route Status - {dispatchable && Actions} @@ -1739,19 +1738,6 @@ function LoadedExportTab({ {r.status} - {dispatchable && ( - - - - )} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx index 63292b0af..51650b459 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseInventoryTable.tsx @@ -160,7 +160,12 @@ export function WarehouseInventoryTable({ {items.map((item) => { const kind = itemKind(item); const busy = busyId === item.id; - const nextAction = getNextInventoryAction(item); + // Per-booking Load and Dispatch are retired: wagon loading happens in + // the train flow and dispatch at the train level (which already + // advances inventory). Only the remaining lifecycle actions render. + const rawNextAction = getNextInventoryAction(item); + const nextAction = + rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction; const canGenerateHandover = item.inspectionStatus === 'PASSED' && Boolean(item.bookingId) && @@ -232,26 +237,15 @@ export function WarehouseInventoryTable({ )} {item.status === 'READY_FOR_PICKUP' && ( - <> - - - + )} {item.status !== 'DISPATCHED' && ( From a7d05fba3413b185f1e49146e3102091238b0094 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 17:04:25 +0000 Subject: [PATCH 09/75] test batch system --- .../modules/bookings/bookings.repository.ts | 7 + .../booking-batch.service.spec.ts | 51 +++++ .../train-scheduling/booking-batch.service.ts | 191 ++++++++++-------- .../corridor-capacity.util.spec.ts | 75 +++++++ .../corridor-capacity.util.ts | 45 ++++- .../train-scheduling/intercity.service.ts | 12 +- .../train-capacity.util.spec.ts | 60 ++++++ .../train-scheduling/train-capacity.util.ts | 74 +++++-- .../BatchScheduleDetailPage.tsx | 9 - 9 files changed, 413 insertions(+), 111 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 087786715..843f91d24 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -999,6 +999,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere('sb.id IS NULL') @@ -1030,6 +1031,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id = :originYardId', { originYardId }) .andWhere('booking.destination_yard_id = :destinationYardId', { @@ -1069,6 +1071,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id') .where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds }) .andWhere('booking.destination_yard_id IN (:...corridorYardIds)', { @@ -1134,6 +1137,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .orderBy('booking.is_government', 'DESC') .addOrderBy('booking.priority_score', 'DESC') @@ -1148,6 +1152,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .where('booking.train_schedule_id = :scheduleId', { scheduleId }) .andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) .getMany(); @@ -1177,6 +1182,8 @@ export class BookingsRepository extends BaseRepository { return this.repository .createQueryBuilder('booking') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .innerJoin( TrainScheduleBooking, 'sb', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 60e2b6767..24e908761 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -752,12 +752,18 @@ describe('BookingBatchService — wagonsFor', () => { null as never, ) as unknown as { wagonsFor(booking: unknown, dims: unknown): number; + needFor(booking: unknown, dims: unknown): { + wagons: number; + weightTons: number; + lengthMeters: number; + }; }; // PW2 box wagon: 70T rated payload, 25.2T tare, 17.066m. const dims = { container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, bulk: { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }, + byWagonTypeId: new Map(), }; const bulk = (cargoTons: number, over: Record = {}) => ({ @@ -815,4 +821,49 @@ describe('BookingBatchService — wagonsFor', () => { }; expect(service.wagonsFor(booking, dims)).toBe(2); }); + + describe('per-booking wagon type (cargo/container type FK)', () => { + // The booking's cargo type rides PW2 (25.2T tare / 70T), but the + // representative bulk fallback is a CW3-ish 23.4T tare. Measuring the + // booking on the fallback under-charged its gross (2100 + 30 × 23.4 = + // 2802 instead of 2856), so the fill loop admitted sets that allocation's + // real-consist check later rejected — after the customer had paid. + const dimsWithTypes = { + container: { lengthMeters: 13.966, tareWeightTons: 22.4, capacityTons: 70 }, + bulk: { lengthMeters: 17.066, tareWeightTons: 23.4, capacityTons: 70 }, + byWagonTypeId: new Map([ + ['pw2-id', { lengthMeters: 17.066, tareWeightTons: 25.2, capacityTons: 70 }], + ]), + }; + + it('charges a bulk booking the tare of ITS wagon type, not the representative', () => { + const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } }); + const need = service.needFor(booking, dimsWithTypes); + expect(need.wagons).toBe(30); + expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation + }); + + it('falls back to the representative dims when no wagon type is configured', () => { + const need = service.needFor(bulk(2100), dimsWithTypes); + expect(need.weightTons).toBe(2802); // 2100 + 30 × 23.4 (legacy behavior) + }); + + it('resolves a container booking through its container type', () => { + const booking = { + freightType: 'CONTAINER', + cargoTotalWeightVgm: 140, + bookingContainers: [ + { + quantity: 2, + wagonsRequired: 2, + containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' }, + }, + ], + }; + const need = service.needFor(booking, dimsWithTypes); + expect(need.wagons).toBe(2); + expect(need.weightTons).toBe(190.4); // 140 + 2 × 25.2 + expect(need.lengthMeters).toBeCloseTo(34.132, 3); // 2 × 17.066, not NW5's 13.966 + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index cf710e429..b8987e4e4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -43,7 +43,6 @@ import { import { WagonTypeDimensions, bookingGrossWeightTons, - bookingTrainLengthMeters, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, trainHardCaps, @@ -61,11 +60,18 @@ import { Capacity, CorridorBudget, CorridorLeg, + OverageTolerance, stopYardsFor, } from './corridor-capacity.util'; export type { Capacity } from './corridor-capacity.util'; +/** + * A train's fill limits: the base caps the corridor budget spends from, plus + * the locomotive overage tolerance spendable only on whole-booking admission. + */ +type TrainLimits = { base: Capacity; tolerance: OverageTolerance }; + /** A day-level pool key: all trains on this route departing on this EAT day. */ interface RouteDayGroup { originYardId: string; @@ -74,13 +80,20 @@ interface RouteDayGroup { day: string; } +/** One wagon type's footprint: its length on the train, the tare it adds to the + * locomotive's gross load, and the payload it carries. */ +type PerWagonDims = { lengthMeters: number; tareWeightTons: number; capacityTons: number }; + /** - * Per-freight-type wagon dimensions used to size a booking's capacity draw: - * its length on the train and the tare it adds to the locomotive's gross load. + * Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds + * every wagon type so a booking is measured on the type its cargo/container type + * actually rides (the same FK resolution allocation uses); `container`/`bulk` are + * representative fallbacks for bookings whose type has no wagon type configured. */ type WagonDims = { - container: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; - bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number }; + container: PerWagonDims; + bulk: PerWagonDims; + byWagonTypeId: Map; }; export type BatchBoardBookingState = @@ -571,7 +584,14 @@ export class BookingBatchService implements OnModuleInit { const partner = await this.dataSource .getRepository(Booking) - .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } }); + .findOne({ + where: { id: partnerId }, + relations: { + company: true, + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply // waits; the partner's later accept will reserve the pair. if (!partner || partner.status !== 'FULLY_EXECUTED') { @@ -1518,24 +1538,27 @@ export class BookingBatchService implements OnModuleInit { if (await this.splitService.findOpenOffer(booking.id)) return null; const wagonDims = await this.loadWagonDims(); - const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); // The wagon-slot axis alone under-constrains the offer. On a weight- or // length-limited train (slots to spare, but e.g. only 798T of pull weight // left) sizing by slots either produced an offer the fits() check below // rejected, or — when the free slots exceeded the booking's own wagon // count — sizeOffer refused outright, so a bulk booking on a weight-bound - // train was never offered a split at all. Size across all three axes. - const perWagon = - booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; - const partial = sizePartialOfferWagons(budget, need.wagons, perWagon); + // train was never offered a split at all. Size across all three axes, + // measured on the booking's REAL wagon type — the same one allocation + // validates against. Bulk splits ride FULL wagons only: the offer never + // part-loads its last wagon. + const perWagon = this.dimsFor(booking, wagonDims); + const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { + fullWagonsOnly: booking.freightType === "BULK", + }); if (!partial) return null; const sized = await this.splitService.sizeOffer( booking, partial.wagons, need.wagons, - bulkCapacityTons, + perWagon.capacityTons, partial.maxCargoTons, ); if (!sized) return null; @@ -1545,13 +1568,9 @@ export class BookingBatchService implements OnModuleInit { weightTons: bookingGrossWeightTons( sized.offeredWeightTons, sized.offeredWagons, - this.tareFor(booking.freightType, wagonDims), - ), - lengthMeters: bookingTrainLengthMeters( - booking.freightType, - sized.offeredWagons, - this.lengthsOf(wagonDims), + perWagon.tareWeightTons, ), + lengthMeters: sized.offeredWagons * perWagon.lengthMeters, }; if (!this.fits(offeredNeed, budget)) return null; @@ -1569,14 +1588,6 @@ export class BookingBatchService implements OnModuleInit { return offeredNeed; } - private async loadBulkWagonCapacityTons(): Promise { - const cw3 = await this.dataSource - .getRepository(WagonType) - .findOne({ where: { code: "CW3" } }); - const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; - return capacity > 0 ? capacity : 60; - } - /** * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides * how to treat a reservation with no deadline (durable path: leave it; timeout @@ -2270,8 +2281,10 @@ export class BookingBatchService implements OnModuleInit { // Consolidation shares TEU slots, never rated payload: the pair still needs // enough wagons to carry its combined cargo, so the weight axis bounds the - // shared count exactly as it bounds an individual booking's. - const capacityTons = this.capacityFor(primary.freightType, wagonDims); + // shared count exactly as it bounds an individual booking's. A pair shares + // wagons, so the primary's wagon type stands for both partners. + const dims = this.dimsFor(primary, wagonDims); + const capacityTons = dims.capacityTons; const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; const byLength = @@ -2287,27 +2300,9 @@ export class BookingBatchService implements OnModuleInit { weightTons: bookingGrossWeightTons( cargoTons, sharedWagons, - this.tareFor(primary.freightType, wagonDims), + dims.tareWeightTons, ), - lengthMeters: bookingTrainLengthMeters( - primary.freightType, - sharedWagons, - this.lengthsOf(wagonDims), - ), - }; - } - - /** Per-wagon tare for the wagon type this freight rides on. */ - private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number { - return freightType === 'BULK' - ? wagonDims.bulk.tareWeightTons - : wagonDims.container.tareWeightTons; - } - - private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } { - return { - container: wagonDims.container.lengthMeters, - bulk: wagonDims.bulk.lengthMeters, + lengthMeters: sharedWagons * dims.lengthMeters, }; } @@ -2388,7 +2383,7 @@ export class BookingBatchService implements OnModuleInit { // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10. const byLength = containerWagonsForLines(booking.bookingContainers ?? []); - const capacityTons = this.capacityFor(booking.freightType, wagonDims); + const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; @@ -2396,15 +2391,6 @@ export class BookingBatchService implements OnModuleInit { return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight); } - private capacityFor( - freightType: string | null | undefined, - wagonDims: WagonDims, - ): number { - return freightType === "BULK" - ? wagonDims.bulk.capacityTons - : wagonDims.container.capacityTons; - } - /** * What one booking consumes along all three capacity axes. * @@ -2415,18 +2401,15 @@ export class BookingBatchService implements OnModuleInit { */ private needFor(booking: Booking, wagonDims: WagonDims): Capacity { const wagons = this.wagonsFor(booking, wagonDims); + const dims = this.dimsFor(booking, wagonDims); return { wagons, weightTons: bookingGrossWeightTons( Number(booking.cargoTotalWeightVgm ?? 0), wagons, - this.tareFor(booking.freightType, wagonDims), - ), - lengthMeters: bookingTrainLengthMeters( - booking.freightType, - wagons, - this.lengthsOf(wagonDims), + dims.tareWeightTons, ), + lengthMeters: wagons * dims.lengthMeters, }; } @@ -2439,14 +2422,16 @@ export class BookingBatchService implements OnModuleInit { } /** - * Hard caps for a schedule's train: gross pull weight, train length, and the + * Caps for a schedule's train: gross pull weight, train length, and the * length-derived wagon slot count (never a fixed 53). Bookings spend against - * these via {@link needFor}, whose weight axis is gross. + * `base` via {@link needFor}, whose weight axis is gross. The locomotive's + * overage tolerance is returned separately — the corridor budget spends it + * only to admit a booking whole, never to size a split. */ private async capacityLimits( locomotive: Locomotive, rules: TrainSchedulingGlobalRules | null, - ): Promise { + ): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { @@ -2466,9 +2451,15 @@ export class BookingBatchService implements OnModuleInit { }, ); return { - wagons: derived.maxWagonSlots, - weightTons: derived.maxWeightTons, - lengthMeters: derived.maxLengthMeters, + base: { + wagons: derived.maxWagonSlots, + weightTons: derived.baseWeightTons, + lengthMeters: derived.baseLengthMeters, + }, + tolerance: { + weightTons: derived.toleranceTons, + lengthMeters: derived.toleranceMeters, + }, }; } @@ -2479,11 +2470,11 @@ export class BookingBatchService implements OnModuleInit { rules: TrainSchedulingGlobalRules | null, ): Promise { const limits = await this.capacityLimits(locomotive, rules); - if ((schedule.maxWagons ?? 0) !== limits.wagons) { + if ((schedule.maxWagons ?? 0) !== limits.base.wagons) { await this.dataSource .getRepository(TrainSchedule) - .update(schedule.id, { maxWagons: limits.wagons }); - schedule.maxWagons = limits.wagons; + .update(schedule.id, { maxWagons: limits.base.wagons }); + schedule.maxWagons = limits.base.wagons; } } @@ -2510,14 +2501,20 @@ export class BookingBatchService implements OnModuleInit { ]; } - /** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */ + /** + * Every wagon type keyed by id (drives per-booking dims via the cargo/container + * type's wagon_type_id FK), plus representative fallbacks per freight type + * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has + * no wagon type configured yet. + */ private async loadWagonDims(): Promise { - const types = await this.dataSource.getRepository(WagonType).find({ - where: [{ code: "NW5" }, { code: "CW3" }], - }); + const types = await this.dataSource.getRepository(WagonType).find(); const byCode = new Map( types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]), ); + const byWagonTypeId = new Map( + types.map((t) => [t.id, wagonTypeDimensionsFromEntity(t)]), + ); const nw5 = byCode.get("NW5"); const cw3 = byCode.get("CW3"); // capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload @@ -2535,6 +2532,33 @@ export class BookingBatchService implements OnModuleInit { tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS, capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS), }, + byWagonTypeId, + }; + } + + /** + * Dimensions of the wagon type THIS booking rides: bulk resolves through its + * cargo type's wagon_type_id, container through the first container line's + * type — the same FK resolution `resolveWagonType` applies when the paid + * booking is allocated. Board/fill math measured on a representative wagon + * while allocation validated the real one let a selected batch flunk the + * post-payment gross-weight check; sharing the resolution closes that gap. + * Falls back to the representative dims when the FK or relation is absent. + */ + private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims { + const fallback = + booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; + const wagonTypeId = + booking.freightType === "BULK" + ? booking.cargoType?.wagonTypeId + : (booking.bookingContainers ?? []) + .map((line) => line.containerType?.wagonTypeId) + .find((id): id is string => Boolean(id)); + const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined; + if (!dims) return fallback; + return { + ...dims, + capacityTons: dims.capacityTons > 0 ? dims.capacityTons : fallback.capacityTons, }; } @@ -2570,11 +2594,11 @@ export class BookingBatchService implements OnModuleInit { */ private async remainingBudget( schedule: TrainSchedule, - limits: Capacity, + limits: TrainLimits, wagonDims: WagonDims, ): Promise { const stops = await this.stopsForSchedule(schedule); - const budget = new CorridorBudget(stops, limits); + const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) .filter((b): b is Booking => Boolean(b)); @@ -2599,9 +2623,12 @@ export class BookingBatchService implements OnModuleInit { const budget = await this.remainingBudget( schedule, { - wagons: schedule.maxWagons ?? 0, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, + base: { + wagons: schedule.maxWagons ?? 0, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, }, wagonDims, ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts new file mode 100644 index 000000000..a00e1f59f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.spec.ts @@ -0,0 +1,75 @@ +import { Capacity, CorridorBudget } from './corridor-capacity.util'; +import { sizePartialOfferWagons } from './train-capacity.util'; + +describe('corridor-capacity.util — overage tolerance', () => { + const pw2 = { lengthMeters: 17.066, capacityTons: 70, tareWeightTons: 25.2 }; + const stops = ['yard-a', 'yard-b']; + const base: Capacity = { wagons: 44, weightTons: 3500, lengthMeters: 760 }; + const tolerance = { weightTons: 90, lengthMeters: 0 }; + + const need = (weightTons: number, wagons = 1, lengthMeters = 17): Capacity => ({ + wagons, + weightTons, + lengthMeters, + }); + + const budgetAt = (usedWeightTons: number): CorridorBudget => { + const budget = new CorridorBudget(stops, base, tolerance); + budget.subtract(need(usedWeightTons, 10, 170), budget.fullLeg()); + return budget; + }; + + it('admits a whole booking that overflows the base cap by less than the tolerance', () => { + // 3500T train, 90T tolerance, 3560T committed: a 25T booking still boards + // entire (3585 ≤ 3590). + const budget = budgetAt(3560); + expect(budget.fits(need(25), budget.fullLeg())).toBe(true); + }); + + it('rejects a whole booking that overflows past the tolerance — no partial admission', () => { + // Same train at 3560T: a 210T booking would need 3770 > 3590 — skipped. + const budget = budgetAt(3560); + expect(budget.fits(need(210), budget.fullLeg())).toBe(false); + }); + + it('caps stacked overage admissions at base + tolerance', () => { + // Small units may keep boarding inside the overage zone, but never past it. + const budget = budgetAt(3560); + budget.subtract(need(25), budget.fullLeg()); // now 3585 committed + expect(budget.fits(need(5), budget.fullLeg())).toBe(true); // 3590 exactly + expect(budget.fits(need(6), budget.fullLeg())).toBe(false); // 3591 > 3590 + }); + + it('excludes the tolerance from remainingFor, so split room never reaches into it', () => { + const budget = budgetAt(3400); + expect(budget.remainingFor(budget.fullLeg()).weightTons).toBe(100); + // Once a whole-unit admission spends the tolerance, base room goes negative. + const over = budgetAt(3560); + expect(over.remainingFor(over.fullLeg()).weightTons).toBe(-60); + }); + + it('yields no split offer once the base cap is spent — tolerance is whole-bookings-only', () => { + // The batch engine sizes splits from remainingFor; at/over base capacity + // that room cannot carry even one part-loaded wagon, so no offer opens. + const over = budgetAt(3560); + const room = over.remainingFor(over.fullLeg()); + expect(sizePartialOfferWagons(room, 15, pw2)).toBeNull(); + }); + + it('still offers a split while committed weight is under the base cap', () => { + // 744T of base room left: the boundary booking is offered the part that + // fits up to 3500, not up to 3590. + const budget = budgetAt(2756); + const room = budget.remainingFor(budget.fullLeg()); + expect(sizePartialOfferWagons(room, 15, pw2)).toEqual({ + wagons: 8, + maxCargoTons: 542.4, + }); + }); + + it('leaves fits() strict when no tolerance is configured', () => { + const strict = new CorridorBudget(stops, base); + strict.subtract(need(3500, 10, 170), strict.fullLeg()); + expect(strict.fits(need(1), strict.fullLeg())).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts index b16b25179..dbf304141 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/corridor-capacity.util.ts @@ -63,18 +63,40 @@ export function stopYardsFor( return [originStationId, destinationStationId]; } -/** Per-edge capacity budget along a schedule's stop list. */ +/** Overage a locomotive may absorb beyond its base caps. */ +export interface OverageTolerance { + weightTons: number; + lengthMeters: number; +} + +/** + * Per-edge capacity budget along a schedule's stop list. + * + * `initial` must be the BASE caps (locomotive floored by rule caps, WITHOUT the + * overage tolerance). The tolerance is passed separately and is spendable only + * by admitting a unit WHOLE via {@link fits} — e.g. base 3500T + 90T tolerance, + * 3560T already committed: a 25T booking still boards entire (3585 ≤ 3590), a + * 210T booking does not. {@link remainingFor} deliberately excludes the + * tolerance (and goes negative once it is consumed), so split/partial offers + * sized from it can only fill up to the base cap and never spend the tolerance. + */ export class CorridorBudget { private readonly edges: Capacity[]; private readonly stopIndex: Map; + private readonly tolerance: OverageTolerance; constructor( readonly stops: string[], initial: Capacity, + tolerance?: Partial | null, ) { const edgeCount = Math.max(1, stops.length - 1); this.edges = Array.from({ length: edgeCount }, () => ({ ...initial })); this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i])); + this.tolerance = { + weightTons: tolerance?.weightTons ?? 0, + lengthMeters: tolerance?.lengthMeters ?? 0, + }; } /** The leg between two stops, or null when they aren't on this corridor in order. */ @@ -99,7 +121,12 @@ export class CorridorBudget { return this.legOf(originYardId, destinationYardId) ?? this.fullLeg(); } - /** Remaining capacity usable by this leg = min across its edges. */ + /** + * Remaining BASE capacity usable by this leg = min across its edges. Excludes + * the overage tolerance and goes negative once a whole-unit admission has + * spent it — sizing a split from this can therefore never reach into the + * tolerance, and yields nothing at all once the base cap is exhausted. + */ remainingFor(leg: CorridorLeg): Capacity { let min = { ...this.edges[leg.fromEdge] }; for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) { @@ -113,8 +140,20 @@ export class CorridorBudget { return min; } + /** + * Whether a unit fits WHOLE on this leg. This is the only place the overage + * tolerance may be spent: the unit boards entirely or not at all, so weight + * and length may dip into the tolerance. Admission keeps the invariant + * `remaining ≥ -tolerance` on every edge, i.e. the train never exceeds + * base + tolerance no matter how many small units board in the overage zone. + */ fits(need: Capacity, leg: CorridorLeg): boolean { - return capacityFits(need, this.remainingFor(leg)); + const remaining = this.remainingFor(leg); + return ( + need.wagons <= remaining.wagons && + need.weightTons <= remaining.weightTons + this.tolerance.weightTons && + need.lengthMeters <= remaining.lengthMeters + this.tolerance.lengthMeters + ); } subtract(need: Capacity, leg: CorridorLeg): void { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index bce4207d3..d2f1dd7b1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -107,7 +107,13 @@ export class IntercityService { for (const bookingId of bookingIds) { const booking = await this.dataSource .getRepository(Booking) - .findOne({ where: { id: bookingId }, relations: { bookingContainers: true } }); + .findOne({ + where: { id: bookingId }, + relations: { + bookingContainers: { containerType: true }, + cargoType: true, + }, + }); if (!booking) { rejected.push({ bookingId, reason: 'Booking not found' }); continue; @@ -205,6 +211,8 @@ export class IntercityService { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) @@ -230,6 +238,8 @@ export class IntercityService { .createQueryBuilder('booking') .leftJoinAndSelect('booking.company', 'company') .leftJoinAndSelect('booking.bookingContainers', 'bookingContainer') + .leftJoinAndSelect('bookingContainer.containerType', 'containerType') + .leftJoinAndSelect('booking.cargoType', 'cargoType') .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .where(`booking.trade_direction = 'DOMESTIC'`) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index cd0411831..dd9234bdb 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -74,6 +74,24 @@ describe('train-capacity.util', () => { expect(derived.maxWeightTons).toBe(3590); }); + it('reports the base caps and tolerance separately so filling can budget on base', () => { + const derived = deriveTrainCapacityFromLocomotive( + { + maxPullWeightTons: 3500, + maxTrainLengthMeters: 760, + overageToleranceTons: 90, + overageToleranceMeters: 20, + }, + [pw2], + ); + expect(derived.baseWeightTons).toBe(3500); + expect(derived.baseLengthMeters).toBe(760); + expect(derived.toleranceTons).toBe(90); + expect(derived.toleranceMeters).toBe(20); + expect(derived.baseWeightTons + derived.toleranceTons).toBe(derived.maxWeightTons); + expect(derived.baseLengthMeters + derived.toleranceMeters).toBe(derived.maxLengthMeters); + }); + it('ignores overage tolerance when unset (strict cap)', () => { const derived = deriveTrainCapacityFromLocomotive( { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, @@ -235,5 +253,47 @@ describe('train-capacity.util', () => { sizePartialOfferWagons({ wagons: 5, weightTons: 20, lengthMeters: 500 }, 15, pw2), ).toBeNull(); }); + + describe('fullWagonsOnly (bulk)', () => { + it('offers only whole full wagons — each costs capacity + tare of gross room', () => { + // 704T of pull weight left. A full PW2 wagon is 70 + 25.2 = 95.2T gross, + // so 7 fit (666.4T) and the 8th (761.6T) does not. Cargo is exactly + // 7 × 70 = 490T — the last wagon is never part-loaded into the leftover. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 704, lengthMeters: 500 }, + 9, + pw2, + { fullWagonsOnly: true }, + ); + expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 }); + }); + + it('never squeezes a part-loaded wagon into leftover weight room', () => { + // Same 744T room as the part-load scenario above: the scan would pick + // 8 wagons hauling 542.4T (last wagon at 52.4/70). Full-wagon sizing + // stops at 7 fully loaded wagons. + const offer = sizePartialOfferWagons( + { wagons: 40, weightTons: 744, lengthMeters: 500 }, + 15, + pw2, + { fullWagonsOnly: true }, + ); + expect(offer).toEqual({ wagons: 7, maxCargoTons: 490 }); + }); + + it('returns null when the room cannot take even one FULL wagon', () => { + // 67.6T left (3590 cap − 3522.4 boarded): a part-loaded wagon would fit + // (25.2 tare + 42.4 cargo) but a full one (95.2 gross) does not — the + // booking must be skipped entirely, not trimmed onto the train. + expect( + sizePartialOfferWagons( + { wagons: 40, weightTons: 67.6, lengthMeters: 500 }, + 3, + pw2, + { fullWagonsOnly: true }, + ), + ).toBeNull(); + }); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 372b29357..463ae7ea8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -52,6 +52,12 @@ export type DerivedTrainCapacity = { maxLengthMeters: number; /** Length-derived slot count. Weight is enforced separately against real cargo. */ maxWagonSlots: number; + /** Caps WITHOUT the overage tolerance — what batch filling budgets against. */ + baseWeightTons: number; + baseLengthMeters: number; + /** Overage spendable only by admitting a booking whole, never by a split. */ + toleranceTons: number; + toleranceMeters: number; }; /** What a consist currently uses, and what is left on each axis. */ @@ -88,28 +94,48 @@ export function grossWagonWeightTons(slot: Pick num(w.lengthMeters)) @@ -137,9 +163,9 @@ export function deriveTrainCapacityFromLocomotive( const minLength = lengths.length ? Math.min(...lengths) : DEFAULT_WAGON_LENGTH_M; const maxWagonSlots = - minLength > 0 ? Math.max(0, Math.floor(maxLengthMeters / minLength)) : 0; + minLength > 0 ? Math.max(0, Math.floor(caps.maxLengthMeters / minLength)) : 0; - return { maxWeightTons, maxLengthMeters, maxWagonSlots }; + return { ...caps, maxWagonSlots }; } /** @@ -265,17 +291,33 @@ export function bookingGrossWeightTons( * returns the count that maximizes the cargo carried, with the cargo cap the * caller should apply. Null when not even one part-loaded wagon fits. The * offer is a strict subset of the booking: never all `bookingWagons`. + * + * `fullWagonsOnly` (bulk): every offered wagon rides at its full rated payload, + * so each wagon costs `capacityTons + tareWeightTons` of gross weight room and + * the offer is the largest whole-wagon count whose gross fits — never a + * part-loaded last wagon squeezed into leftover pull weight. */ export function sizePartialOfferWagons( room: { wagons: number; weightTons: number; lengthMeters: number }, bookingWagons: number, perWagon: { capacityTons: number; tareWeightTons: number; lengthMeters: number }, + opts?: { fullWagonsOnly?: boolean }, ): { wagons: number; maxCargoTons: number } | null { const maxByLength = perWagon.lengthMeters > 0 ? Math.floor(room.lengthMeters / perWagon.lengthMeters) : room.wagons; const ceiling = Math.min(room.wagons, maxByLength, bookingWagons - 1); + + if (opts?.fullWagonsOnly) { + const grossPerWagon = perWagon.capacityTons + perWagon.tareWeightTons; + const maxByWeight = + grossPerWagon > 0 ? Math.floor(room.weightTons / grossPerWagon) : 0; + const wagons = Math.min(ceiling, maxByWeight); + if (wagons < 1) return null; + return { wagons, maxCargoTons: round3(wagons * perWagon.capacityTons) }; + } + let wagons = 0; let bestCargoTons = 0; for (let w = 1; w <= ceiling; w += 1) { diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 558f15ab4..34ff013cb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -20,7 +20,6 @@ import { AlertTriangle, ArrowLeft, ArrowLeftRight, - Boxes, CalendarDays, CheckCircle2, ClipboardCheck, @@ -890,14 +889,6 @@ export default function BatchScheduleDetailPage() { Date: Thu, 9 Jul 2026 18:10:58 +0000 Subject: [PATCH 10/75] add Excel import functionality for container bookings --- .../contracts/booking-request.service.ts | 5 + .../contract-booking.completion.spec.ts | 149 +++++++++++++ .../contracts/contract-booking.service.ts | 61 ++++++ .../modules/contracts/contracts.service.ts | 134 ++++++++---- .../train-scheduling/booking-split.service.ts | 3 + apps/edr-freight-web/backoffice/package.json | 1 + .../contracts/GlCreateBookingForm.tsx | 135 ++++++++++++ .../gl-booking-form/container-excel.ts | 200 ++++++++++++++++++ apps/edr-freight-web/portal/package.json | 1 + .../src/pages/contracts/NewContractPage.tsx | 7 + .../src/pages/contracts/NewShipmentPage.tsx | 138 ++++++++++++ .../new-contract-form/ContractDocsEditor.tsx | 84 +++++++- .../new-shipment-form/container-excel.ts | 200 ++++++++++++++++++ pnpm-lock.yaml | 6 + 14 files changed, 1074 insertions(+), 50 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/container-excel.ts diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 4752b004f..e038c7bff 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -51,6 +51,11 @@ export class BookingRequestService { const contract = await this.contractsService.findById(contractId); await this.contractsService.assertCustomerCanAccessContract(userId, contract); this.assertGeneralCustoms(contract); + if (contract.status === 'CONTRACT_CLOSED') { + throw new ConflictException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } if (contract.status !== 'CONTRACT_ACTIVE') { throw new ConflictException( 'The contract must be active before requesting a shipment.', diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts new file mode 100644 index 000000000..fb57be209 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -0,0 +1,149 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ContractBookingService } from './contract-booking.service'; +import { Contract } from './entities/contract.entity'; + +/** + * Contract auto-completion by quantity cap. Once a GENERAL contract's capped + * scope is fully consumed (e.g. a split remainder rebooked), the contract moves + * to CONTRACT_CLOSED even inside its validity window, and further bookings are + * blocked — including while a booking window is open. Released capacity + * (cancelled/expired booking) reopens the contract on the next attempt. + */ +describe('ContractBookingService — quantity-cap completion', () => { + function makeService() { + const contractsRepository = { + findByIdWithRelations: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + const service = new ContractBookingService( + contractsRepository as never, + {} as never, // bookingsRepository + {} as never, // bookingPricingService + {} as never, // consolidationService + {} as never, // containerTypesService + {} as never, // ruleEngineService + {} as never, // milestoneService + {} as never, // workflowService + {} as never, // invoiceService + {} as never, // dataSource + {} as never, // trainSchedulingService + ); + return { service, contractsRepository }; + } + + type WithPrivate = { + maybeCompleteContract: (c: Contract) => Promise; + }; + + const generalContract = (status: string): Contract => + ({ + id: 'c-1', + reference: 'CTR-1', + contractKind: 'GENERAL', + status, + }) as Contract; + + it('closes a GENERAL contract when every capped line is exhausted', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([ + { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, + { containerSize: '40FT', cap: 4, booked: 4, remaining: 0 }, + ]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_CLOSED', + }); + }); + + it('absorbs bulk-ton float dust when judging exhaustion', async () => { + const { service, contractsRepository } = makeService(); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 100, booked: 99.9995, remaining: 0.0005 }]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('FULLY_EXECUTED'), + ); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_CLOSED', + }); + }); + + it('keeps the contract open while any capped line has capacity left', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([ + { containerSize: '20FT', cap: 10, booked: 10, remaining: 0 }, + { containerSize: '40FT', cap: 4, booked: 3, remaining: 1 }, + ]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('never closes an uncapped contract', async () => { + const { service, contractsRepository } = makeService(); + jest.spyOn(service, 'computeCapacity').mockResolvedValue([]); + + await (service as never as WithPrivate).maybeCompleteContract( + generalContract('CONTRACT_ACTIVE'), + ); + + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('never closes a ONE_TIME contract (single-slot rule governs it)', async () => { + const { service, contractsRepository } = makeService(); + const spy = jest.spyOn(service, 'computeCapacity'); + + await (service as never as WithPrivate).maybeCompleteContract({ + id: 'c-1', + contractKind: 'ONE_TIME', + status: 'FULLY_EXECUTED', + } as Contract); + + expect(spy).not.toHaveBeenCalled(); + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('rejects a new booking on a completed contract even inside an open window', async () => { + const { service, contractsRepository } = makeService(); + contractsRepository.findByIdWithRelations.mockResolvedValue( + generalContract('CONTRACT_CLOSED'), + ); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 10, booked: 10, remaining: 0 }]); + + await expect( + service.createUnderContract('c-1', {} as never, null, null), + ).rejects.toThrow(BadRequestException); + expect(contractsRepository.update).not.toHaveBeenCalled(); + }); + + it('reopens a completed contract when capacity was released', async () => { + const { service, contractsRepository } = makeService(); + contractsRepository.findByIdWithRelations.mockResolvedValue( + generalContract('CONTRACT_CLOSED'), + ); + jest + .spyOn(service, 'computeCapacity') + .mockResolvedValue([{ cap: 10, booked: 8, remaining: 2 }]); + + // The create path continues past the gate and dies later on the bare mocks — + // only the reopen transition is under test here. + await service.createUnderContract('c-1', {} as never, null, null).catch(() => undefined); + + expect(contractsRepository.update).toHaveBeenCalledWith('c-1', { + status: 'CONTRACT_ACTIVE', + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 46fb61db1..70083a2ae 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -83,6 +83,24 @@ export class ContractBookingService { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + // A contract whose quantity cap was fully booked is completed — no further + // bookings, even while contract validity and a booking window are still + // open. Capacity released after closure (a cancelled/expired booking) + // reopens the contract on the next booking attempt. + if (contract.status === 'CONTRACT_CLOSED') { + const capacity = await this.computeCapacity(contract); + const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0); + if (!hasRoom) { + throw new BadRequestException( + 'This contract is completed — the full contracted quantity has been booked.', + ); + } + await this.contractsRepository.update(contract.id, { + status: 'CONTRACT_ACTIVE', + } as never); + contract.status = 'CONTRACT_ACTIVE'; + } + // GL Ethiopia is identified by the dedicated contract create-booking permission // (granted to the edr_gl_ethiopia preset). const isGlActor = @@ -291,6 +309,9 @@ export class ContractBookingService { if (!parked.paired) { // Waiting for a partner — stop here. The booking sits in // PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs. + // A parked booking still holds contract capacity, so the cap may + // already be exhausted by it. + await this.maybeCompleteContract(contract); const pendingResult = await this.bookingsRepository.findByIdWithFiles( booking.id, ); @@ -304,6 +325,8 @@ export class ContractBookingService { generalCustoms, ); + await this.maybeCompleteContract(contract); + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); return { booking: result ?? booking, warnings }; } @@ -606,6 +629,44 @@ export class ContractBookingService { }); } + /** + * Complete the contract once its quantity cap is fully consumed. Runs after + * every booking created under a GENERAL contract (including a split remainder + * being rebooked): when no capped scope line has capacity left, the contract + * moves to CONTRACT_CLOSED even though its validity window is still open — + * blocking further bookings and shipment requests, including inside an open + * booking window. Never throws: a status hiccup must not undo the booking + * that was just created. + */ + private async maybeCompleteContract(contract: Contract): Promise { + try { + // ONE_TIME contracts are governed by the single-active-booking slot (and + // are promoted to GENERAL on split), so only GENERAL completes by cap. + if (contract.contractKind !== 'GENERAL') return; + if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return; + const capacity = await this.computeCapacity(contract); + if (capacity.length === 0) return; // uncapped — completes only by expiry + // 0.001 tolerance absorbs bulk-ton float rounding (split weights round to + // 3 decimals); container caps are integers and unaffected. + const exhausted = capacity.every( + (c) => c.remaining != null && c.remaining <= 0.001, + ); + if (!exhausted) return; + await this.contractsRepository.update(contract.id, { + status: 'CONTRACT_CLOSED', + } as never); + this.logger.log( + `Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`, + ); + } catch (err) { + this.logger.error( + `Could not evaluate completion for contract ${contract.id}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + /** * Quantities already booked under a contract that still hold capacity. Excludes * bookings that never shipped (CANCELLED / REJECTED / EXPIRED). diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 095857959..517d818bf 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -265,10 +265,11 @@ export class ContractsService { } } - // Attach the company profile's onboarding / business-license documents to the - // contract by reference. The separate "Documents" intake step was removed — - // the profile documents are simply carried onto every contract automatically. - await this.attachProfileDocuments(contract.id, companyProfileId); + // Attach the company's onboarding documents (TIN, licenses, IDs) and the + // profile's business-license documents to the contract by reference. The + // separate "Documents" intake step was removed — the profile documents are + // simply carried onto every contract automatically. + await this.attachProfileDocuments(contract.id, companyId ?? null, companyProfileId); return { contract: await this.findById(contract.id), warnings }; } @@ -316,51 +317,95 @@ export class ContractsService { } /** - * Copy a company profile's stored business-license / onboarding documents onto - * a contract by reference (no byte re-upload). Codes are slugged from each - * document name so they group under "Profile documents" on the contract detail - * page. No-op when the contract has no profile or the profile has no documents. + * Copy the company's onboarding documents (TIN certificate, commercial / + * investment license, national ID, passport — resource "companies", coded by + * the upload-setting fileKey) and the company profile's business-license + * documents (resource "company_profiles") onto a contract by reference (no + * byte re-upload). Idempotent: codes already present on the contract — user + * uploads or an earlier carry — are never duplicated or overwritten, so it is + * safe to run on every create and update. No-op when there is nothing to copy. */ private async attachProfileDocuments( contractId: string, + companyId: string | null, companyProfileId: string | null, ): Promise { - if (!companyProfileId) return; - // Business-license files are FileRecords (resource "company_profiles"); carry - // the live ones by reference. Staged/pending uploads are excluded by code. - const records = await this.filesService.findByResource( - companyProfileId, - 'company_profiles', + if (!companyId && !companyProfileId) return; + + const existingCodes = new Set( + (await this.filesService.findByResource(contractId, 'contracts')).map( + (r) => r.code, + ), ); - const docs = records - .filter((r) => r.code === 'business_license') - .map((r) => ({ - name: r.name, - url: r.url, - size: r.size, - mimeType: r.mimeType, - })); + const docs: Array<{ + code: string; + name: string; + url: string; + size: number; + mimeType?: string; + }> = []; + + if (companyId) { + // Company onboarding documents keep their fileKey codes (tin_certificate, + // commercial_license, …) so the portal can match them against the + // onboarding upload-setting fields. Re-uploads append rows, so keep only + // the newest record per code. + const companyRecords = await this.filesService.findByResource( + companyId, + 'companies', + ); + const latestByCode = new Map(); + for (const r of companyRecords) { + const prev = latestByCode.get(r.code); + if (!prev || r.createdAt > prev.createdAt) latestByCode.set(r.code, r); + } + for (const r of latestByCode.values()) { + if (existingCodes.has(r.code)) continue; + docs.push({ + code: r.code, + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + }); + } + } + + if (companyProfileId) { + // Business-license files are FileRecords (resource "company_profiles"); + // carry the live ones by reference. Staged/pending uploads are excluded by + // code. Codes are slugged from each document name so they group under + // "Profile documents" on the contract detail page. + const records = await this.filesService.findByResource( + companyProfileId, + 'company_profiles', + ); + const slug = (name: string) => + name + .toLowerCase() + .replace(/\.[a-z0-9]+$/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'profile_document'; + + records + .filter((r) => r.code === 'business_license') + .forEach((r, i) => { + const code = `${slug(r.name)}_${i + 1}`; + if (existingCodes.has(code)) return; + docs.push({ + code, + name: r.name, + url: r.url, + size: r.size, + mimeType: r.mimeType, + }); + }); + } + if (docs.length === 0) return; - const slug = (name: string) => - name - .toLowerCase() - .replace(/\.[a-z0-9]+$/, '') - .replace(/[^a-z0-9]+/g, '_') - .replace(/^_+|_+$/g, '') || 'profile_document'; - try { - await this.filesService.attachExistingFiles( - contractId, - 'contracts', - docs.map((d, i) => ({ - code: `${slug(d.name)}_${i + 1}`, - name: d.name, - url: d.url, - size: d.size, - mimeType: d.mimeType, - })), - ); + await this.filesService.attachExistingFiles(contractId, 'contracts', docs); } catch { // Non-fatal — the contract is still valid without the carried documents. } @@ -481,6 +526,15 @@ export class ContractsService { await this.filesService.uploadMany(id, 'contracts', files); } + // Re-carry any company/profile document that is still missing from the + // contract (runs after the upload so fresh replacements keep their slot). + // Backfills contracts created before profile documents were carried over. + await this.attachProfileDocuments( + id, + existing.companyId ?? null, + existing.companyProfileId ?? null, + ); + return { contract: await this.findById(id), warnings }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts index 104db4545..ad0967cf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -36,6 +36,9 @@ export interface SizedOffer { * rows, so reducing the lines releases it automatically) and can be rebooked in * any later window within contract validity. A ONE_TIME contract is promoted to * GENERAL on split (see applySplit) so its remainder is actually rebookable. + * Once the remainder is rebooked and the cap hits zero, ContractBookingService + * completes the contract (CONTRACT_CLOSED): no further bookings or shipment + * requests, even while validity and a booking window are still open. */ @Injectable() export class BookingSplitService { diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index b89807b72..140bc1d01 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -39,6 +39,7 @@ "stream-browserify": "^3.0.0", "tailwind-merge": "^3.6.0", "tinymce": "^8.6.0", + "xlsx": "^0.18.5", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index a744453c0..33254d3ce 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -12,6 +12,7 @@ import { Button, Center, Divider, + FileButton, Group, Loader, Modal, @@ -31,7 +32,9 @@ import { CalendarDays, CheckCircle2, ChevronLeft, + FileDown, FileText, + FileUp, MapPin, Package, Receipt, @@ -54,6 +57,10 @@ import { type GlShipmentQuantities, } from "./gl-booking-form/total"; import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice"; +import { + downloadContainerImportTemplate, + parseContainerExcel, +} from "./gl-booking-form/container-excel"; import { fieldStyles, StepCard, @@ -392,6 +399,57 @@ export default function GlCreateBookingForm() { // bulk needs a positive quantity with hazardous/reefer portions bounded by it. const [showErrors, setShowErrors] = useState(false); + // Excel import: one row per container. All-or-nothing — a file with any bad + // row is rejected with row-numbered errors so nothing is silently dropped. + const [importErrors, setImportErrors] = useState([]); + const [importSummary, setImportSummary] = useState(null); + const importResetRef = useRef<(() => void) | null>(null); + const excelOpts = { + allowedSizes: containerSizes, + includeHazardous: contract?.isHazardous ?? false, + includeReefer: contract?.isReefer ?? false, + }; + + const handleImportFile = async (file: File | null) => { + // Reset the hidden input so re-picking the same (fixed) file re-fires. + importResetRef.current?.(); + if (!file) return; + const { rows, errors } = await parseContainerExcel(file, excelOpts); + if (errors.length > 0) { + setImportSummary(null); + setImportErrors(errors); + return; + } + // Replace only the lines for sizes present in the file; a contracted size + // the file omits keeps whatever was already entered for it. + setContainerLines((prev) => + containerSizes.map((size) => { + const imported = rows.filter((r) => r.containerSize === size); + if (imported.length === 0) { + return ( + prev.find((l) => l.containerSize === size) ?? { + containerSize: size, + units: [emptyUnit()], + } + ); + } + return { + containerSize: size, + units: imported.map((r) => ({ + containerNumber: r.containerNumber, + sealNumber: r.sealNumber, + vgmTons: r.vgmTons, + hazardous: r.hazardous, + reefer: r.reefer, + })), + }; + }), + ); + setImportErrors([]); + setShowErrors(false); + setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); + }; + const unitErrors = useMemo(() => { if (!isContainer) return []; const numberCounts = new Map(); @@ -740,6 +798,83 @@ export default function GlCreateBookingForm() { description="Enter the quantity and per-container details for each size in the contract scope." /> + {containerSizes.length > 0 && ( + + + + + Import containers from Excel + + + One row per container. Importing fills the lines below + for the sizes in the file. + + + + + + {(props) => ( + + )} + + + + {importErrors.length > 0 && ( + } + title="Import failed — fix the file and try again" + mt="sm" + > + + {importErrors.slice(0, 8).map((msg, i) => ( + + {msg} + + ))} + {importErrors.length > 8 && ( + + …and {importErrors.length - 8} more. + + )} + + + )} + {importSummary && ( + } + mt="sm" + > + {importSummary} + + )} + + )} {containerLines.length === 0 ? ( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts new file mode 100644 index 000000000..bbd3acf90 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/container-excel.ts @@ -0,0 +1,200 @@ +import * as XLSX from "xlsx"; + +// Excel import for container shipments: one spreadsheet row per physical +// container, mirroring the manual per-unit fields (number, seal, VGM) plus the +// hazardous/reefer flags when the contract allows them. The parser is +// all-or-nothing — any bad row rejects the file with row-numbered errors so a +// partial import can never silently drop containers. + +// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit. +const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/; + +export interface ContainerExcelOptions { + /** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */ + allowedSizes: string[]; + includeHazardous: boolean; + includeReefer: boolean; +} + +export interface ImportedContainerRow { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: string; + hazardous: boolean; + reefer: boolean; +} + +export interface ContainerExcelResult { + rows: ImportedContainerRow[]; + errors: string[]; +} + +type ColumnKey = + | "containerSize" + | "containerNumber" + | "sealNumber" + | "vgmTons" + | "hazardous" + | "reefer"; + +/** Match a header cell to a known column, tolerant of casing/spacing/units. */ +function headerKey(raw: string): ColumnKey | null { + const h = raw.toLowerCase().replace(/[^a-z]/g, ""); + if (!h) return null; + if (h.includes("size")) return "containerSize"; + if (h.includes("seal")) return "sealNumber"; + if (h.includes("vgm") || h.includes("weight")) return "vgmTons"; + if (h.includes("hazard")) return "hazardous"; + if (h.includes("reefer") || h.includes("refrigerat")) return "reefer"; + // After the more specific matches: "Container Number", "Container No", … + if (h.includes("container") || h.includes("number")) return "containerNumber"; + return null; +} + +/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */ +function normalizeSize(raw: string, allowed: string[]): string | null { + const digits = raw.replace(/[^0-9]/g, ""); + if (!digits) return null; + return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null; +} + +function parseFlag(raw: string): boolean { + const v = raw.trim().toLowerCase(); + return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x"; +} + +/** + * Parse an uploaded workbook into one row per container. Returns either the + * full row set or the list of row-numbered problems (never both). + */ +export async function parseContainerExcel( + file: File, + opts: ContainerExcelOptions, +): Promise { + let sheet: XLSX.WorkSheet | undefined; + try { + const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" }); + sheet = workbook.Sheets[workbook.SheetNames[0]]; + } catch { + return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] }; + } + if (!sheet) { + return { rows: [], errors: ["The file has no sheets."] }; + } + + const grid = XLSX.utils.sheet_to_json(sheet, { + header: 1, + raw: false, + defval: "", + }); + + // First row with a recognizable column is the header; everything above + // (titles, blank rows) is ignored. + let headerRowIdx = -1; + let columns: Array = []; + for (let i = 0; i < grid.length; i++) { + const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? ""))); + if (mapped.includes("containerNumber") && mapped.includes("containerSize")) { + headerRowIdx = i; + columns = mapped; + break; + } + } + if (headerRowIdx < 0) { + return { + rows: [], + errors: [ + 'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.', + ], + }; + } + if (!columns.includes("vgmTons")) { + return { + rows: [], + errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'], + }; + } + + const rows: ImportedContainerRow[] = []; + const errors: string[] = []; + const numberCounts = new Map(); + + for (let i = headerRowIdx + 1; i < grid.length; i++) { + const cells = grid[i] ?? []; + if (cells.every((c) => String(c ?? "").trim() === "")) continue; + const rowNo = i + 1; // 1-based, as shown in Excel + + const cell = (key: ColumnKey) => { + const idx = columns.indexOf(key); + return idx >= 0 ? String(cells[idx] ?? "").trim() : ""; + }; + + const size = normalizeSize(cell("containerSize"), opts.allowedSizes); + if (!size) { + errors.push( + `Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`, + ); + } + + const containerNumber = cell("containerNumber").toUpperCase(); + if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) { + errors.push( + `Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`, + ); + } else { + numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1); + } + + const vgmRaw = cell("vgmTons"); + const vgm = Number(vgmRaw); + if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) { + errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`); + } + + rows.push({ + containerSize: size ?? "", + containerNumber, + sealNumber: cell("sealNumber"), + vgmTons: vgmRaw, + hazardous: opts.includeHazardous && parseFlag(cell("hazardous")), + reefer: opts.includeReefer && parseFlag(cell("reefer")), + }); + } + + numberCounts.forEach((count, num) => { + if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`); + }); + + if (rows.length === 0 && errors.length === 0) { + errors.push("The sheet has no container rows below the header."); + } + + return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] }; +} + +/** Generate and download the simple import template with one sample row per size. */ +export function downloadContainerImportTemplate(opts: ContainerExcelOptions) { + const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"]; + if (opts.includeHazardous) headers.push("Hazardous (YES/NO)"); + if (opts.includeReefer) headers.push("Reefer (YES/NO)"); + + const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"]; + const sampleRows = sizes.map((size, i) => { + const row: Array = [ + size, + `MSCU${String(1234567 + i).padStart(7, "0")}`, + `SL${String(482910 + i)}`, + size.startsWith("40") ? 28 : 24.5, + ]; + if (opts.includeHazardous) row.push("NO"); + if (opts.includeReefer) row.push("NO"); + return row; + }); + + const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]); + sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) })); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, sheet, "Containers"); + XLSX.writeFile(workbook, "container-import-template.xlsx"); +} diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index 433b83c1a..2cfd488d6 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -36,6 +36,7 @@ "recharts": "^3.8.1", "socket.io-client": "^4.8.3", "tailwind-merge": "^3.6.0", + "xlsx": "^0.18.5", "zod": "^4.4.3", "zustand": "^5.0.0" }, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 9b24d3b8b..33a89e3e1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -59,6 +59,7 @@ import { ContractDocsEditor, documentSettingCode, missingRequiredDocKeys, + useCompanyDocuments, } from "./new-contract-form/ContractDocsEditor"; import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import type { ProfileTypeValue } from "@/services/companies.service"; @@ -116,6 +117,9 @@ export default function NewContractPage({ }), enabled: isEdit, }); + // Profile documents (TIN, licenses, IDs) satisfy requirements too — the API + // carries them onto the contract on save. + const companyDocs = useCompanyDocuments(); // Contract creation is gated on profile approval, same as bookings. if (!auth.isPending && auth.company && !auth.canBook) { @@ -526,6 +530,7 @@ export default function NewContractPage({ editDocSettingQuery.data, editContract, editDocuments, + companyDocs, ); if (missing.length > 0) { setShowDocErrors(true); @@ -643,6 +648,7 @@ export default function NewContractPage({ editDocSettingQuery.data, editContract, editDocuments, + companyDocs, ); if (missing.length > 0) { setShowDocErrors(true); @@ -846,6 +852,7 @@ export default function NewContractPage({ editDocSettingQuery.data, editContract, editDocuments, + companyDocs, ).map((k) => [k, "Required"]), ) : {} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 9410fbc7b..4a073a59a 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -9,6 +9,7 @@ import { Button, Center, Divider, + FileButton, Group, Loader, Modal, @@ -26,6 +27,8 @@ import { CalendarDays, CheckCircle2, ChevronLeft, + FileDown, + FileUp, MapPin, Package, Receipt, @@ -51,6 +54,10 @@ import { initialShipmentFormValues, } from "./new-shipment-form/schema"; import { computeShipmentTotal } from "./new-shipment-form/total"; +import { + downloadContainerImportTemplate, + parseContainerExcel, +} from "./new-shipment-form/container-excel"; import { ContractCapacityNotice } from "./new-shipment-form/ContractCapacityNotice"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -931,6 +938,60 @@ function CargoStep({ const lines = form.watch("containers") ?? []; + // Excel import: one row per container. All-or-nothing — a file with any bad + // row is rejected with row-numbered errors so nothing is silently dropped. + const [importErrors, setImportErrors] = useState([]); + const [importSummary, setImportSummary] = useState(null); + const importResetRef = useRef<(() => void) | null>(null); + const excelOpts = { + allowedSizes: sizes, + includeHazardous: contract.isHazardous ?? false, + includeReefer: contract.isReefer ?? false, + }; + + const handleImportFile = async (file: File | null) => { + // Reset the hidden input so re-picking the same (fixed) file re-fires. + importResetRef.current?.(); + if (!file) return; + const { rows, errors } = await parseContainerExcel(file, excelOpts); + if (errors.length > 0) { + setImportSummary(null); + setImportErrors(errors); + return; + } + // Replace only the lines for sizes present in the file; a contracted size + // the file omits keeps whatever was already entered for it. + const current = form.getValues("containers") ?? []; + const next = sizes.map((size) => { + const imported = rows.filter((r) => r.containerSize === size); + if (imported.length === 0) { + return ( + current.find((l) => l.containerSize === size) ?? { + containerSize: size, + quantity: "1", + hazardousQuantity: "0", + reeferQuantity: "0", + units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }], + } + ); + } + return { + containerSize: size, + quantity: String(imported.length), + hazardousQuantity: String(imported.filter((r) => r.hazardous).length), + reeferQuantity: String(imported.filter((r) => r.reefer).length), + units: imported.map((r) => ({ + containerNumber: r.containerNumber, + sealNumber: r.sealNumber, + vgmTons: r.vgmTons, + })), + }; + }); + form.setValue("containers", next, { shouldValidate: true, shouldDirty: true }); + setImportErrors([]); + setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`); + }; + if (isContainer) { return ( @@ -940,6 +1001,83 @@ function CargoStep({ description="Enter the quantity and per-container details for each size in your contract scope." /> + {sizes.length > 0 && ( + + + + + Import containers from Excel + + + One row per container. Importing fills the lines below for + the sizes in your file. + + + + + + {(props) => ( + + )} + + + + {importErrors.length > 0 && ( + } + title="Import failed — fix the file and try again" + mt="sm" + > + + {importErrors.slice(0, 8).map((msg, i) => ( + + {msg} + + ))} + {importErrors.length > 8 && ( + + …and {importErrors.length - 8} more. + + )} + + + )} + {importSummary && ( + } + mt="sm" + > + {importSummary} + + )} + + )} {lines.map((line, index) => ( latest.get(c)!); } +/** + * The company's onboarding documents (TIN certificate, commercial license, + * national ID, …) from the profile. Contracts carry these automatically on + * save; the edit page also shows them directly so they are always visible even + * on contracts created before the carry-over existed. + */ +export function useCompanyDocuments(): CompanyDocument[] { + const auth = useAuth(); + const companyId = auth.company?.company?.id as string | undefined; + const query = useQuery({ + ...api.companies.documents.queryOptions({ + input: { companyId: companyId ?? "" }, + }), + enabled: Boolean(companyId), + }); + return query.data ?? []; +} + +/** Latest company document per code, excluding codes already on the contract. */ +function profileFallbackDocs( + companyDocs: CompanyDocument[], + contractFiles: ContractFile[], +): CompanyDocument[] { + const onContract = new Set(contractFiles.map((f) => f.code)); + const latest = new Map(); + for (const d of companyDocs) { + const prev = latest.get(d.code); + if (!prev || d.uploadedAt > prev.uploadedAt) latest.set(d.code, d); + } + return [...latest.values()].filter((d) => !onContract.has(d.code)); +} + /** * Document replace/upload block for an existing contract. Lists the documents * already on file (latest upload per code) and renders the onboarding-driven @@ -72,8 +105,25 @@ export function ContractDocsEditor({ }), ); + const companyDocs = useCompanyDocuments(); const files = contract.files ?? []; - const onFile = useMemo(() => dedupeLatestByCode(files), [files]); + const onFile = useMemo(() => { + const contractRows = dedupeLatestByCode(files).map((f) => ({ + id: f.id, + code: f.code, + name: f.name, + fromProfile: false, + })); + // Profile documents not yet carried onto the contract still show — they are + // attached automatically on the next save. + const profileRows = profileFallbackDocs(companyDocs, files).map((d) => ({ + id: d.id, + code: d.code, + name: d.name, + fromProfile: true, + })); + return [...contractRows, ...profileRows]; + }, [files, companyDocs]); return ( @@ -115,12 +165,21 @@ export function ContractDocsEditor({ - - - - On file - - + {file.fromProfile ? ( + + + + From profile + + + ) : ( + + + + On file + + + )} + + Page {pagination.pageIndex + 1} of {pageCount} + + + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 34ff013cb..114b2e742 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -25,6 +25,7 @@ import { ClipboardCheck, Clock, FileSignature, + Hash, Hourglass, Layers, Package, @@ -621,6 +622,9 @@ export default function BatchScheduleDetailPage() { api.trainScheduling.scheduleDetail.queryOptions({ input: { id: scheduleId ?? "", freightType: "CONTAINER" }, enabled: Boolean(scheduleId), + // Composition data only changes through mutations, which invalidate the + // whole train-scheduling root — no need to refetch on remounts in between. + staleTime: 5 * 60_000, }), ); @@ -744,7 +748,13 @@ export default function BatchScheduleDetailPage() { items={[ { label: "Operations" }, { label: "Batch board", href: "/dashboard/operations/batch-board" }, - { label: data.trainNumber ?? data.routeName ?? "Schedule" }, + { + label: + data.scheduleReference ?? + data.trainNumber ?? + data.routeName ?? + "Schedule", + }, ]} /> @@ -791,6 +801,11 @@ export default function BatchScheduleDetailPage() { {data.trainNumber ?? data.routeName ?? "Schedule"} + {data.scheduleReference ? ( + }> + {data.scheduleReference} + + ) : null} {data.windowPhase ? ( QUERY_KEYS.TRAIN_SCHEDULING.schedules(), ), - batchBoard: endpoint( + batchBoard: endpoint< + { filters?: BatchBoardFilters }, + BatchBoardListResponse + >( "train-scheduling", "batch-board", - () => trainSchedulingService.getBatchBoard(), - () => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + ({ filters }) => trainSchedulingService.getBatchBoard(filters), + ({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(filters), ), allBookingWindows: endpoint( diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 6a4c0cb1c..b97ac2bc3 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -21,6 +21,19 @@ export interface BookingListFilter { bookingType?: string; tradeDirection?: string; paymentCurrency?: string; + paymentStatus?: string; + /** ISO date-time — bookings created on/after. */ + createdFrom?: string; + /** ISO date-time — bookings created on/before (pass end-of-day for inclusive). */ + createdTo?: string; + /** ISO date-time — bookings scheduled on/after. */ + scheduledFrom?: string; + /** ISO date-time — bookings scheduled on/before (pass end-of-day for inclusive). */ + scheduledTo?: string; + originYardId?: string; + destinationYardId?: string; + /** "true" = government bookings only, "false" = private only. */ + isGovernment?: "true" | "false"; page?: number; pageSize?: number; sortBy?: string; @@ -130,6 +143,14 @@ export const bookingsService = { if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency; + if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus; + if (filter.createdFrom) params.createdFrom = filter.createdFrom; + if (filter.createdTo) params.createdTo = filter.createdTo; + if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom; + if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo; + if (filter.originYardId) params.originYardId = filter.originYardId; + if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId; + if (filter.isGovernment) params.isGovernment = filter.isGovernment; } const response = await client.get(B.LIST_SUMMARY, { params, @@ -154,6 +175,14 @@ export const bookingsService = { if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency; + if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus; + if (filter.createdFrom) params.createdFrom = filter.createdFrom; + if (filter.createdTo) params.createdTo = filter.createdTo; + if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom; + if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo; + if (filter.originYardId) params.originYardId = filter.originYardId; + if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId; + if (filter.isGovernment) params.isGovernment = filter.isGovernment; } const response = await client.get(B.BASE, { params, diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index be05c6d8c..fb26b9605 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -3,7 +3,8 @@ import { api as client } from "../auth/http"; import { unwrap } from "@/utils/endpoint"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { - BatchBoardSchedule, + BatchBoardFilters, + BatchBoardListResponse, BatchBoardScheduleDetail, BookableSchedule, BookingWindow, @@ -100,9 +101,25 @@ export const trainSchedulingService = { return unwrap(response.data); }, - getBatchBoard: async (): Promise => { - const response = await client.get( + getBatchBoard: async ( + filters: BatchBoardFilters = {}, + ): Promise => { + const params: Record = {}; + if (filters.page) params.page = filters.page; + if (filters.pageSize) params.pageSize = filters.pageSize; + if (filters.statuses?.length) params.statuses = filters.statuses.join(","); + if (filters.bookingWindowStatus) + params.bookingWindowStatus = filters.bookingWindowStatus; + if (filters.search?.trim()) params.search = filters.search.trim(); + if (filters.departureFrom) params.departureFrom = filters.departureFrom; + if (filters.departureTo) params.departureTo = filters.departureTo; + if (filters.createdFrom) params.createdFrom = filters.createdFrom; + if (filters.createdTo) params.createdTo = filters.createdTo; + if (filters.sortBy) params.sortBy = filters.sortBy; + if (filters.sortOrder) params.sortOrder = filters.sortOrder; + const response = await client.get( URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD, + { params }, ); return unwrap(response.data); }, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index c09c067d8..129171b7f 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -257,11 +257,14 @@ export interface StaffBookingWindow { export interface BatchBoardSchedule { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; destination: string | null; scheduleDate: string | null; + createdAt: string | null; status: string; bookingWindowStatus: string; direction: string | null; @@ -300,6 +303,37 @@ export interface BatchBoardSchedule { bookings: BatchBoardBooking[]; } +export type BatchBoardSortField = + | "createdAt" + | "scheduledDepartureDate" + | "trainNumber" + | "status"; + +/** Server-side filters for the paginated batch board list. */ +export interface BatchBoardFilters { + page?: number; + pageSize?: number; + /** Subset of schedule statuses; omit for all (incl. arrived/cancelled). */ + statuses?: TrainScheduleStatus[]; + bookingWindowStatus?: "OPEN" | "FULL" | "CLOSED"; + /** Matches train number, route yards, stations, locomotive code. */ + search?: string; + departureFrom?: string; + departureTo?: string; + createdFrom?: string; + createdTo?: string; + sortBy?: BatchBoardSortField; + sortOrder?: "ASC" | "DESC"; +} + +export interface BatchBoardListResponse { + items: BatchBoardSchedule[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + export type BookingAllocationStatus = | "NOT_ATTEMPTED" | "ASSIGNED" @@ -337,6 +371,8 @@ export interface BatchWindowGroup { export interface BatchBoardScheduleDetail { scheduleId: string; + /** Human-facing schedule reference (S-YYYY-NNNNN). */ + scheduleReference: string | null; trainNumber: string | null; routeName: string | null; origin: string | null; @@ -506,6 +542,8 @@ export interface TrainScheduleDetail { capacityTons: number; lengthMeters: number; assignedWeightTons: number; + /** Empty-wagon weight from the wagon type — gross = tare + cargo. */ + tareWeightTons?: number | null; status?: string; physicalWagonId?: string | null; physicalWagonNumber?: string | null; diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx index 4aa131c23..fbb8f97c2 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractClearanceAction.tsx @@ -83,7 +83,7 @@ export function ContractClearanceAction({ centered overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} > - + ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 13404f8f6..82c692a90 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -142,7 +142,7 @@ function ProgressTracker({ const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; return ( - /* Scrollable on mobile so 5 stages never overflow */ + /* Scrollable on mobile so the stages never overflow */ -
+
{PROGRESS_STAGES.map((stage, idx) => { const state = idx < current ? "done" : idx === current ? "active" : "idle"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index 4b32a059c..7b91f8250 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -3,6 +3,7 @@ import { FileText, MapPin, PackageCheck, + PackageOpen, ShieldCheck, Ship, Train, @@ -54,12 +55,22 @@ export const PROGRESS_STAGES = [ statuses: ["EXPIRED", "IN_TRANSIT"], }, { - // ARRIVED: cargo unloaded at the booking's own destination yard (segment - // corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so - // this stage also lights up from the assigned train's own status - // (trainScheduleStatus === "ARRIVED") — see resolveStage. + // The train reached the booking's destination yard — cargo may still be + // on board. No booking status of its own: it lights up from the assigned + // train's status (trainScheduleStatus === "ARRIVED") while the booking is + // still IN_TRANSIT — see resolveStage. Bookings unloaded mid-corridor (or + // auto-unloaded on a checkpoint) jump straight past it to Unloading. label: "Arrival", icon: MapPin, + statuses: [], + }, + { + // ARRIVED: cargo unloaded off the train at the booking's own destination + // yard. Unloading is automatic — the API alights the booking the moment a + // checkpoint is recorded at its destination yard (or, as a fallback, when + // the train's final arrival is logged). + label: "Unloading", + icon: PackageOpen, statuses: ["ARRIVED"], }, { @@ -69,17 +80,17 @@ export const PROGRESS_STAGES = [ }, ]; -/** Stage index of the Arrival step (train ARRIVED, cargo not yet delivered). */ +/** Stage index of the Arrival step (train ARRIVED, cargo not yet unloaded). */ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( (s) => s.label === "Arrival", ); /** * Stage for a booking, factoring in the assigned train's operational status: - * a booking with per-booking journey data reaches ARRIVED when it is unloaded - * at its own destination yard; a legacy booking is stuck at IN_TRANSIT between - * dispatch and delivery, so once its train has ARRIVED the tracker advances to - * the Arrival stage. + * a booking with per-booking journey data reaches ARRIVED (the Unloading + * stage) when it is unloaded at its own destination yard; a legacy booking is + * stuck at IN_TRANSIT between dispatch and delivery, so once its train has + * ARRIVED the tracker advances to the Arrival stage. */ export function resolveStage(booking: { status: string; @@ -181,10 +192,10 @@ export const STATUS_MAP: Record< stage: 6, }, ARRIVED: { - title: "Arrived at destination", + title: "Unloaded at destination", description: "Your cargo has been unloaded at its destination yard and is being prepared for release.", - stage: 7, + stage: 8, }, OPERATION_REQUEST_PENDING: { title: "Operation request under review", @@ -251,7 +262,7 @@ export const STATUS_MAP: Record< title: "Contract closed", description: "This general contract is closed — its reserved quantity has been used or its window has elapsed.", - stage: 8, + stage: 9, }, PRICE_CHANGED_PENDING_CONFIRM: { title: "Price changed — confirm to proceed", @@ -277,12 +288,12 @@ export const STATUS_MAP: Record< COMPLETED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 8, + stage: 9, }, DELIVERED: { title: "Service complete", description: "Cargo delivered and service successfully terminated.", - stage: 8, + stage: 9, }, REJECTED: { title: "Booking rejected", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index 23676efe8..0cabf0878 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -42,7 +42,7 @@ function BookingActionModalBody({ const flow = useClearanceFlow(booking); const reference = booking.reference; - const handleSubmit = () => flow.submitDocuments(); + const handleSubmit = () => flow.submitDocuments({ onSuccess: onClose }); const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose }); return ( diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx index 6fc88c678..67b3fead8 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractClearancePanel.tsx @@ -39,6 +39,8 @@ export interface ContractClearancePanelProps { tradeDirection?: string; /** Show the loading state without the surrounding Paper (e.g. inside a modal). */ bare?: boolean; + /** Called after a successful document submission (e.g. to close the host modal). */ + onSubmitted?: () => void; } /** @@ -52,6 +54,7 @@ export function ContractClearancePanel({ contractId, tradeDirection = "IMPORT", bare, + onSubmitted, }: ContractClearancePanelProps) { const queryClient = useQueryClient(); const [pending, setPending] = useState>({}); @@ -77,6 +80,13 @@ export function ContractClearancePanel({ queryClient.invalidateQueries({ queryKey: api.contracts.get.queryKey({ id: contractId }), }); + // Submitting moves the contract to CLEARANCE_UNDER_REVIEW — refresh every + // contracts-list query (prefix key) so the /contracts table row and its + // action button update without a page reload. + queryClient.invalidateQueries({ + queryKey: api.contracts.list.queryKey(), + }); + onSubmitted?.(); }, }); diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index c6b97fab1..49af6b736 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -519,6 +519,8 @@ export interface IContract extends BaseEntity { companyId?: string | null; companyProfileId?: string | null; + /** Loaded company relation (list + detail responses include it). */ + company?: BookingRequestCompany | null; isGovernment: boolean; governmentInstitution?: string | null; From 3443b7964464eec69a0ddd4a7dfd1ee7712bd432 Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 9 Jul 2026 21:13:07 +0000 Subject: [PATCH 12/75] make a contrat template --- apps/edr-freight-api/src/app.module.ts | 2 + .../src/contracts/contract-article.util.ts | 63 ++ .../contract-document-view-model.builder.ts | 30 +- .../contract-dynamic-template.spec.ts | 151 +++ .../contracts/contract-renderer.service.ts | 36 + .../contracts/contract-view-model.builder.ts | 16 + .../templates/_partials/dynamic_articles.hbs | 23 + .../contracts/templates/_partials/styles.hbs | 227 ++++- .../src/contracts/templates/edr-dynamic.hbs | 184 ++++ .../2090000000000-CreateContractTemplates.ts | 58 ++ .../contract-templates.controller.ts | 97 ++ .../contract-templates.module.ts | 21 + .../contract-templates.repository.ts | 31 + .../contract-templates.service.spec.ts | 66 ++ .../contract-templates.service.ts | 276 ++++++ .../dto/contract-template.dto.ts | 134 +++ .../entities/contract-template.entity.ts | 77 ++ .../contracts/contract-transition.service.ts | 11 + .../src/modules/contracts/contracts.module.ts | 4 + .../seed/data/contract-template-defaults.ts | 873 ++++++++++++++++++ apps/edr-freight-web/backoffice/src/App.tsx | 25 + .../useContractTemplates.ts | 98 ++ .../ContractTemplateEditorPage.tsx | 468 ++++++++++ .../ContractTemplatesPage.tsx | 152 +++ .../TemplatePreviewModal.tsx | 49 + .../services/contract-templates.service.ts | 112 +++ 26 files changed, 3243 insertions(+), 41 deletions(-) create mode 100644 apps/edr-freight-api/src/contracts/contract-article.util.ts create mode 100644 apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts create mode 100644 apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs create mode 100644 apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs create mode 100644 apps/edr-freight-api/src/migrations/2090000000000-CreateContractTemplates.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/contract-templates.module.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts create mode 100644 apps/edr-freight-api/src/seed/data/contract-template-defaults.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/contract-templates/useContractTemplates.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contract_templates/TemplatePreviewModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/contract-templates.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 9561a7b73..afb214137 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -41,6 +41,7 @@ import { NotificationsModule } from "./modules/notifications/notifications.modul import { NotificationInboxModule } from "./modules/notification-inbox/notification-inbox.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { OtpModule } from "./modules/otp/otp.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; @@ -158,6 +159,7 @@ import { LoggerMiddleware } from "./logger.middleware"; NotificationInboxModule, FileUploadSettingsModule, DropdownSettingsModule, + ContractTemplatesModule, OtpModule, RuleEngineModule, BackofficeModule, diff --git a/apps/edr-freight-api/src/contracts/contract-article.util.ts b/apps/edr-freight-api/src/contracts/contract-article.util.ts new file mode 100644 index 000000000..5f0c86a6c --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-article.util.ts @@ -0,0 +1,63 @@ +import Handlebars from 'handlebars'; + +/** One numbered clause of a dynamic article, with optional nested bullets. */ +export interface RenderedClause { + text: string; + bullets: string[]; +} + +/** A dynamic article ready for the Handlebars template. */ +export interface RenderedArticle { + number: number; + title: string; + /** Set (instead of clauses) when the body is a single plain paragraph. */ + paragraph?: string; + clauses: RenderedClause[]; +} + +/** + * Parse a template article body into clauses. Format: one clause per line; + * lines prefixed with "- " become bullets nested under the preceding clause. + * A body that reduces to a single clause without bullets renders as a plain + * paragraph rather than a numbered list of one. + */ +export function parseArticleBody(body: string): Pick { + const lines = (body ?? '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const clauses: RenderedClause[] = []; + for (const line of lines) { + if (line.startsWith('- ')) { + const bullet = line.slice(2).trim(); + if (clauses.length === 0) { + clauses.push({ text: bullet, bullets: [] }); + } else { + clauses[clauses.length - 1].bullets.push(bullet); + } + } else { + clauses.push({ text: line, bullets: [] }); + } + } + + if (clauses.length === 1 && clauses[0].bullets.length === 0) { + return { paragraph: clauses[0].text, clauses: [] }; + } + return { clauses }; +} + +/** + * Interpolate Handlebars placeholders ({{client.companyName}}, {{contractDate}}, + * …) inside admin-authored template text against the contract view model. + * Malformed placeholders must never break document generation — fall back to + * the raw text. + */ +export function interpolateTemplateText(text: string, context: unknown): string { + if (!text || !text.includes('{{')) return text ?? ''; + try { + return Handlebars.compile(text)(context); + } catch { + return text; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 05061ed9a..559415fd8 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -8,6 +8,7 @@ import { ContractSignerRole, } from '../modules/contracts/entities/contract-signature.entity'; import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service'; +import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service'; import { ContractTemplateResolver } from './contract-template.resolver'; import { getTemplateMeta } from './contract-template.registry'; import { ContractViewModel } from './contract-view-model.builder'; @@ -74,6 +75,7 @@ export class ContractDocumentViewModelBuilder { constructor( private readonly contractsRepository: ContractsRepository, private readonly templateResolver: ContractTemplateResolver, + private readonly contractTemplates: ContractTemplatesService, ) {} async build( @@ -86,7 +88,32 @@ export class ContractDocumentViewModelBuilder { const templateKey = contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract)); - const template = getTemplateMeta(templateKey); + let template = getTemplateMeta(templateKey); + + // Prefer the admin-editable DB template matching the contract's + // direction/freight pair; fall back to the code-defined generic layout + // when none is active. + const dynamicSource = await this.contractTemplates.findActiveForContract( + contract.tradeDirection, + contract.freightType, + ); + const dynamicTemplate = dynamicSource + ? { + code: dynamicSource.code, + name: dynamicSource.name, + documentTitle: dynamicSource.documentTitle, + whereasClauses: dynamicSource.whereasClauses ?? [], + articles: dynamicSource.articles ?? [], + } + : undefined; + if (dynamicTemplate) { + template = { + ...template, + title: dynamicTemplate.name, + templateFile: 'edr-dynamic.hbs', + }; + } + const pricing = this.buildPricing(contract); const signatures = await this.loadSignatures(contractId); @@ -139,6 +166,7 @@ export class ContractDocumentViewModelBuilder { hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, + dynamicTemplate, }; return { contract, view }; diff --git a/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts new file mode 100644 index 000000000..032030243 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-dynamic-template.spec.ts @@ -0,0 +1,151 @@ +import { parseArticleBody, interpolateTemplateText } from './contract-article.util'; +import { ContractRendererService } from './contract-renderer.service'; +import { getTemplateMeta } from './contract-template.registry'; +import type { ContractViewModel } from './contract-view-model.builder'; + +describe('parseArticleBody', () => { + it('numbers each non-empty line as a clause', () => { + const parsed = parseArticleBody('First clause.\nSecond clause.\n\nThird clause.'); + expect(parsed.paragraph).toBeUndefined(); + expect(parsed.clauses.map((c) => c.text)).toEqual([ + 'First clause.', + 'Second clause.', + 'Third clause.', + ]); + }); + + it('nests "- " lines as bullets under the previous clause', () => { + const parsed = parseArticleBody('Rates are:\n- USD 10 per ton\n- USD 20 per wagon\nPayment in advance.'); + expect(parsed.clauses).toHaveLength(2); + expect(parsed.clauses[0].bullets).toEqual(['USD 10 per ton', 'USD 20 per wagon']); + expect(parsed.clauses[1].text).toBe('Payment in advance.'); + }); + + it('renders a single bare line as a paragraph', () => { + const parsed = parseArticleBody('This Agreement becomes effective when signed.'); + expect(parsed.paragraph).toBe('This Agreement becomes effective when signed.'); + expect(parsed.clauses).toEqual([]); + }); +}); + +describe('interpolateTemplateText', () => { + it('fills placeholders from the view model', () => { + expect( + interpolateTemplateText('Valid until August 31, {{contractYear}}.', { + contractYear: 2026, + }), + ).toBe('Valid until August 31, 2026.'); + }); + + it('falls back to raw text on malformed placeholders', () => { + expect(interpolateTemplateText('Broken {{#if}} tag', {})).toBe('Broken {{#if}} tag'); + }); +}); + +describe('dynamic template rendering (edr-dynamic.hbs)', () => { + const renderer = new ContractRendererService(); + renderer.onModuleInit(); + + function dynamicView(): ContractViewModel { + const meta = getTemplateMeta('IMP_BULK_USD_FORWARDING'); + return { + bookingId: 'test-id', + reference: 'EDR/CT/2026/0042', + status: 'CONTRACT_READY', + templateKey: 'IMP_BULK_USD_FORWARDING', + template: { ...meta, title: 'Bulk Import Contract', templateFile: 'edr-dynamic.hbs' }, + contractDate: '1 January 2026', + contractYear: 2026, + client: { + companyName: 'Abyssinia Trading PLC', + companyAddress: 'Bole Sub-city, Addis Ababa', + companyLocation: 'Ethiopia', + phone: '+251900000000', + email: 'test@example.com', + tinNumber: '1234567890', + vatNumber: 'VAT-001', + fanNumber: 'FAN-001', + businessLicense: 'BL-001', + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: { + originLabel: 'Nagad', + destinationLabel: 'Galaan Multipurpose Port', + tradeDirection: 'IMPORT', + freightType: 'BULK', + serviceType: 'Rail + clearance', + scheduledDate: '—', + contractType: 'GENERAL', + cargoDescription: 'Steel billets', + totalWeightVgm: '—', + equipmentReturn: '—', + hazardousLabel: 'No', + firstMilePickupAddress: '—', + lastMileDeliveryAddress: '—', + }, + pricing: { + displayMode: 'UNIT_RATES', + unitRates: [ + { label: 'Rail transport', unitPrice: 59.4, unit: 'ton', currency: 'USD' }, + ], + currency: 'USD', + equipmentReturn: '—', + originLabel: 'Nagad', + destinationLabel: 'Galaan Multipurpose Port', + } as unknown as ContractViewModel['pricing'], + signatures: [], + canSignCustomer: false, + canSignStaff: false, + hasContractDocument: false, + hasCustomerSignature: false, + hasStaffSignature: false, + dynamicTemplate: { + code: 'IMPORT_BULK', + name: 'Bulk Import Contract', + documentTitle: 'Bulk Cargo Transportation and Customs Clearance Services', + whereasClauses: ['The Client has agreed to engage the Service Provider.'], + articles: [ + { + id: 'objective', + title: 'Objective of the Services', + body: 'Integrated logistics services including:\n- Rail transport to GMP\n- Customs clearance', + order: 1, + }, + { + id: 'duration', + title: 'Duration', + body: 'Valid until August 31, {{contractYear}}.', + order: 2, + }, + ], + }, + }; + } + + it('renders numbered dynamic articles with bullets and interpolation', () => { + const html = renderer.render(dynamicView()); + expect(html).toContain('Bulk Cargo Transportation and Customs Clearance Services'); + expect(html).toContain('Article 1'); + expect(html).toContain('Objective of the Services'); + expect(html).toContain('Rail transport to GMP'); + expect(html).toContain('Valid until August 31, 2026.'); + expect(html).toContain('Abyssinia Trading PLC'); + expect(html).toContain('Annex A — Commercial Schedule'); + // Greenish theme marker from styles.hbs + expect(html).toContain('#1b9e7a'); + }); + + it('keeps the generic layout when no dynamic template is attached', () => { + const view = dynamicView(); + delete view.dynamicTemplate; + view.template = getTemplateMeta('IMP_BULK_USD_FORWARDING'); + const html = renderer.render(view); + expect(html).toContain('Article 5: Contract Price'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts index dd539df25..7b3301c87 100644 --- a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -3,6 +3,11 @@ import * as fs from 'fs'; import * as path from 'path'; import Handlebars from 'handlebars'; +import { + interpolateTemplateText, + parseArticleBody, + RenderedArticle, +} from './contract-article.util'; import { ContractViewModel } from './contract-view-model.builder'; @Injectable() @@ -31,9 +36,40 @@ export class ContractRendererService implements OnModuleInit { return template({ ...view, paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD', + ...this.buildDynamicSections(view), }); } + /** + * Turn the DB-backed dynamic template (when present) into render-ready data: + * interpolate placeholders against the view model, then parse each article + * body into numbered clauses with nested bullets. + */ + private buildDynamicSections(view: ContractViewModel): { + dynamicDocumentTitle?: string; + dynamicWhereas?: string[]; + dynamicArticles?: RenderedArticle[]; + } { + const dyn = view.dynamicTemplate; + if (!dyn || dyn.articles.length === 0) return {}; + + const articles = [...dyn.articles] + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((article, index) => ({ + number: index + 1, + title: interpolateTemplateText(article.title, view), + ...parseArticleBody(interpolateTemplateText(article.body, view)), + })); + + return { + dynamicDocumentTitle: interpolateTemplateText(dyn.documentTitle, view), + dynamicWhereas: dyn.whereasClauses.map((clause) => + interpolateTemplateText(clause, view), + ), + dynamicArticles: articles, + }; + } + private getCompiled(fileName: string): Handlebars.TemplateDelegate { const cached = this.compiled.get(fileName); if (cached) return cached; diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 41a2f3b44..d90b8e709 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -17,6 +17,21 @@ export interface ContractSignatureView { signatureImageUrl?: string | null; } +/** + * DB-backed contract template (freight.contract_templates) attached to the + * view model when an active template matches the contract's direction/freight + * pair. The renderer turns its articles into numbered clauses and switches to + * the dedicated edr-dynamic.hbs layout; absent, the legacy generic layout with + * code-defined clause packs is used. + */ +export interface ContractDynamicTemplateView { + code: string; + name: string; + documentTitle: string; + whereasClauses: string[]; + articles: Array<{ id: string; title: string; body: string; order: number }>; +} + export interface ContractViewModel { bookingId: string; reference: string; @@ -65,6 +80,7 @@ export interface ContractViewModel { hasContractDocument: boolean; hasCustomerSignature: boolean; hasStaffSignature: boolean; + dynamicTemplate?: ContractDynamicTemplateView; } @Injectable() diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs new file mode 100644 index 000000000..717fbde8f --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/dynamic_articles.hbs @@ -0,0 +1,23 @@ +{{#each dynamicArticles}} +
+

Article {{number}}{{title}}

+ {{#if paragraph}} +

{{paragraph}}

+ {{else}} +
    + {{#each clauses}} +
  1. + {{text}} + {{#if bullets.length}} +
      + {{#each bullets}} +
    • {{this}}
    • + {{/each}} +
    + {{/if}} +
  2. + {{/each}} +
+ {{/if}} +
+{{/each}} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index 43a5495ec..0207ff9fb 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -4,11 +4,11 @@ body { margin: 0; - background: #f5f7fb; - color: #111827; + background: #f3f8f5; + color: #16241d; font-family: "Times New Roman", Times, serif; font-size: 10.5pt; - line-height: 1.48; + line-height: 1.5; } .contract { @@ -21,25 +21,28 @@ h1, h2, h3, p { margin-top: 0; } h1 { - color: #0f2742; - font-size: 18pt; - line-height: 1.25; + color: #0a3d2e; + font-size: 17pt; + letter-spacing: 0.02em; + line-height: 1.3; margin-bottom: 10px; text-align: center; text-transform: uppercase; } h2 { - border-bottom: 1.5px solid #1e3a5f; - color: #1e3a5f; - font-size: 12pt; - letter-spacing: 0.03em; - margin: 18px 0 10px; + border-bottom: 1.5px solid #1b9e7a; + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 11.5pt; + letter-spacing: 0.04em; + margin: 20px 0 10px; padding-bottom: 5px; text-transform: uppercase; } h3 { - color: #0f2742; - font-size: 10.8pt; + color: #0a3d2e; + font-family: Arial, sans-serif; + font-size: 10.5pt; margin: 12px 0 6px; } p { margin-bottom: 8px; } @@ -52,16 +55,17 @@ page-break-inside: avoid; } + /* ── Brand header ─────────────────────────────────────────────────────── */ .brand-row { align-items: center; - border-bottom: 3px solid #1e3a5f; + border-bottom: 3px double #1b9e7a; display: flex; gap: 14px; padding-bottom: 14px; } .logo-mark { align-items: center; - background: #1e3a5f; + background: linear-gradient(135deg, #0e5b45 0%, #1b9e7a 100%); border-radius: 8px; color: #fff; display: flex; @@ -74,7 +78,7 @@ width: 72px; } .kicker { - color: #1e3a5f; + color: #0e5b45; font-family: Arial, sans-serif; font-size: 10pt; font-weight: 700; @@ -83,36 +87,74 @@ text-transform: uppercase; } .muted { - color: #6b7280; + color: #5c6f66; font-family: Arial, sans-serif; font-size: 9pt; margin: 0; } + .muted-note { + color: #5c6f66; + font-size: 9.5pt; + } + /* ── Cover page ───────────────────────────────────────────────────────── */ .cover { + display: flex; + flex-direction: column; min-height: 255mm; position: relative; } .cover-title { - margin: 54mm 0 34mm; + margin: 34mm 0 22mm; text-align: center; } + .cover-rule { + background: #1b9e7a; + height: 2px; + margin: 14px auto; + width: 46mm; + } .document-label { - color: #6b7280; + color: #1b9e7a; font-family: Arial, sans-serif; - font-size: 10pt; + font-size: 11pt; font-weight: 700; - letter-spacing: 0.12em; - margin-bottom: 10px; + letter-spacing: 0.18em; + margin-bottom: 6px; text-transform: uppercase; } + .cover-for, + .cover-between { + color: #5c6f66; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-style: italic; + margin: 10px 0 6px; + } + .cover-party { + color: #0a3d2e; + font-family: Arial, sans-serif; + font-size: 12pt; + font-weight: 700; + margin: 4px 0; + } .summary-line { - color: #374151; + color: #38493f; font-family: Arial, sans-serif; font-size: 9.5pt; margin-top: 12px; } + .cover-year { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 13pt; + font-weight: 700; + letter-spacing: 0.1em; + margin-top: auto; + text-align: right; + } + /* ── Tables ───────────────────────────────────────────────────────────── */ table { border-collapse: collapse; width: 100%; @@ -129,7 +171,7 @@ .details-table td, .schedule th, .schedule td { - border: 1px solid #cbd5e1; + border: 1px solid #c9e4d9; padding: 7px 8px; text-align: left; vertical-align: top; @@ -137,35 +179,46 @@ .meta-grid th, .details-table th, .schedule th { - background: #eef4fb; - color: #1e3a5f; + background: #e9f6f0; + color: #0e5b45; font-family: Arial, sans-serif; font-size: 8.5pt; text-transform: uppercase; } - .schedule tbody tr:nth-child(even) td { background: #f8fafc; } + .schedule tbody tr:nth-child(even) td { background: #f5faf8; } .total-row td { - background: #e8f0f8 !important; - color: #0f2742; + background: #ddf2e9 !important; + color: #0a3d2e; font-weight: 700; } + /* ── Parties ──────────────────────────────────────────────────────────── */ .lead { - color: #374151; + color: #38493f; font-size: 10.5pt; } + .between-label { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-weight: 700; + letter-spacing: 0.08em; + margin: 10px 0 4px; + text-transform: uppercase; + } .party-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; + margin-top: 12px; } .party-card { - border: 1px solid #cbd5e1; + border: 1px solid #c9e4d9; border-radius: 8px; padding: 12px; } .party-card h3 { - background: #1e3a5f; + background: #0e5b45; border-radius: 5px; color: #fff; font-family: Arial, sans-serif; @@ -175,7 +228,7 @@ text-transform: uppercase; } .party-name { - color: #0f2742; + color: #0a3d2e; font-weight: 700; margin-bottom: 8px; } @@ -185,7 +238,7 @@ margin: 0; } dt { - color: #475569; + color: #47594f; font-family: Arial, sans-serif; font-size: 8.5pt; font-weight: 700; @@ -196,6 +249,81 @@ padding: 2px 0; } + /* ── Recitals ─────────────────────────────────────────────────────────── */ + .whereas-label { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 9pt; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + } + .now-therefore { + color: #0a3d2e; + font-weight: 700; + margin-top: 10px; + } + + /* ── Dynamic articles ─────────────────────────────────────────────────── */ + .article-heading { + align-items: baseline; + display: flex; + gap: 10px; + } + .article-no { + color: #1b9e7a; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + white-space: nowrap; + } + .article-name { color: #0e5b45; } + .article-paragraph { margin: 4px 0 0; } + ol.clauses { + counter-reset: clause; + list-style: none; + margin: 6px 0 0; + padding-left: 0; + } + ol.clauses > li { + counter-increment: clause; + margin-bottom: 6px; + padding-left: 24px; + position: relative; + text-align: justify; + } + ol.clauses > li::before { + color: #0e5b45; + content: counter(clause) "."; + font-family: Arial, sans-serif; + font-size: 9.5pt; + font-weight: 700; + left: 0; + position: absolute; + top: 1px; + } + ul.clause-bullets { + margin: 5px 0 2px; + padding-left: 16px; + } + ul.clause-bullets > li { + list-style: none; + margin-bottom: 3px; + padding-left: 12px; + position: relative; + } + ul.clause-bullets > li::before { + color: #1b9e7a; + content: "▪"; + font-size: 8pt; + left: 0; + position: absolute; + top: 1px; + } + + /* ── Signatures ───────────────────────────────────────────────────────── */ .signatures { display: grid; gap: 18px; @@ -204,13 +332,13 @@ page-break-inside: avoid; } .sig-block { - border: 1.5px solid #1e3a5f; + border: 1.5px solid #1b9e7a; border-radius: 8px; min-height: 96mm; padding: 12px; } .sig-title { - color: #1e3a5f; + color: #0e5b45; font-family: Arial, sans-serif; font-size: 9pt; font-weight: 700; @@ -219,7 +347,7 @@ } .sig-image-box { align-items: center; - border: 1px dashed #94a3b8; + border: 1px dashed #7fbfa9; display: flex; height: 28mm; justify-content: center; @@ -231,21 +359,40 @@ max-width: 70mm; } .sig-placeholder { - color: #94a3b8; + color: #7fbfa9; font-family: Arial, sans-serif; font-size: 8.5pt; } .sig-line { - border-top: 1px solid #111827; + border-top: 1px solid #16241d; margin-top: 16px; padding-top: 5px; } .sig-meta { - color: #475569; + color: #47594f; font-size: 9pt; margin: 4px 0; } + /* ── Witnesses ────────────────────────────────────────────────────────── */ + .witnesses { margin-top: 20px; } + .witness-table { + font-size: 9.5pt; + margin-top: 6px; + } + .witness-table th, + .witness-table td { + border-bottom: 1px solid #c9e4d9; + padding: 9px 8px; + text-align: left; + } + .witness-table th { + color: #0e5b45; + font-family: Arial, sans-serif; + font-size: 8.5pt; + text-transform: uppercase; + } + @media print { body { background: #fff; } .contract { diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs new file mode 100644 index 000000000..4ba35ea3b --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -0,0 +1,184 @@ + + + + + {{dynamicDocumentTitle}} — {{reference}} + {{> styles}} + + +
+ + {{!-- ─────────────────────────── Cover page ─────────────────────────── --}} +
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Freight Transport Services

+
+
+ +
+

Contract Agreement

+
+

for

+

{{dynamicDocumentTitle}}

+

between

+

Ethio-Djibouti Standard Gauge Railway Share Company

+

and

+

{{client.companyName}}

+
+
+ +
+ + + + + + + + + + + + +
Contract Ref No.{{reference}}Contract Date{{contractDate}}
Trade Direction{{schedule.tradeDirection}}Freight Type{{schedule.freightType}}
+ +

{{contractYear}}

+ + + {{!-- ──────────────────────────── Preamble ──────────────────────────── --}} +
+

Parties to the Agreement

+

+ This Contract Agreement is made on {{contractDate}}. +

+

Between

+

+ Ethio-Djibouti Standard Gauge Railway Share Company (EDR), a share company + incorporated under the laws of the Federal Democratic Republic of Ethiopia (FDRE), having its + principal place of business at {{provider.address}} (hereinafter referred to as the + "Service Provider"); +

+

And

+

+ {{client.companyName}}, an organization incorporated under the laws of the + Federal Democratic Republic of Ethiopia (FDRE), having its principal place of business at + {{client.companyAddress}} (hereinafter referred to as the "Client"). +

+ +
+
+

Service Provider

+

{{provider.name}}

+
+
Address
{{provider.address}}
+
Phone
{{provider.phone}}
+
Email
{{provider.email}}
+
TIN
{{provider.tinNumber}}
+
+
+
+

Client

+

{{client.companyName}}

+
+
Address
{{client.companyAddress}}
+
Location
{{client.companyLocation}}
+
Phone
{{client.phone}}
+
Email
{{client.email}}
+
TIN
{{client.tinNumber}}
+
VAT
{{client.vatNumber}}
+
Business license
{{client.businessLicense}}
+
+
+
+
+ + {{#if dynamicWhereas.length}} +
+

Recitals

+ {{#each dynamicWhereas}} +

Whereas {{this}}

+ {{/each}} +

Now, therefore, the parties agree as follows:

+
+ {{/if}} + + {{!-- ──────────────────────── Dynamic articles ──────────────────────── --}} + {{> dynamic_articles}} + + {{!-- ─────────────────── Commercial schedule (annex) ─────────────────── --}} +
+

Annex A — Commercial Schedule

+ + + + + + + + + + + + + + + + + + + + + +
Route{{schedule.originLabel}} → {{schedule.destinationLabel}}Service type{{schedule.serviceType}}
Cargo{{schedule.cargoDescription}}Hazardous cargo{{schedule.hazardousLabel}}
Equipment return{{schedule.equipmentReturn}}Payment currency{{paymentArticle}}
+ + {{#if pricing.unitRates.length}} +

Agreed Unit Rates

+

+ The rates below are the frozen unit prices applicable to this contract. Quantities and resulting + totals are determined per shipment at booking time. +

+ + + + + + {{#each pricing.unitRates}} + + + + + {{/each}} + +
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
+ {{/if}} +
+ + {{!-- ────────────────────────── Signatures ───────────────────────────── --}} +
+

Execution

+

+ In witness whereof, the parties hereto have caused this contract to be signed in their respective + names as of the day and year first above written. The signatories confirm that they are fully + authorized to sign and execute this Contract Agreement. +

+ {{> signatures_block}} + +
+

Witnesses

+ + + + + + + + +
NameSignatureDate
1.
2.
+
+
+ + + diff --git a/apps/edr-freight-api/src/migrations/2090000000000-CreateContractTemplates.ts b/apps/edr-freight-api/src/migrations/2090000000000-CreateContractTemplates.ts new file mode 100644 index 000000000..a553319cc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2090000000000-CreateContractTemplates.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Creates freight.contract_templates — the six editable contract document + * templates (direction × freight type) whose dynamic articles drive the + * generated contract PDF — and seeds them from the EDR reference contract + * documents. Seeding is idempotent (ON CONFLICT (code) DO NOTHING) so admin + * edits are never overwritten by redeploys. + */ +export class CreateContractTemplates2090000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(40) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + document_title VARCHAR(300) NOT NULL, + whereas_clauses JSONB NOT NULL DEFAULT '[]', + articles JSONB NOT NULL DEFAULT '[]', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_contract_templates_code UNIQUE (code) + ); + `); + + for (const seed of CONTRACT_TEMPLATE_DEFAULTS) { + const articles = seed.articles.map((article, index) => ({ + ...article, + order: index + 1, + })); + await queryRunner.query( + ` + INSERT INTO freight.contract_templates + (code, name, description, document_title, whereas_clauses, articles) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (code) DO NOTHING; + `, + [ + seed.code, + seed.name, + seed.description, + seed.documentTitle, + JSON.stringify(seed.whereasClauses), + JSON.stringify(articles), + ], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_templates;`); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts new file mode 100644 index 000000000..8cf1c5671 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.controller.ts @@ -0,0 +1,97 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Put, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { FreightAdmin } from "../../common/booking-guards"; +import { ContractTemplatesService } from "./contract-templates.service"; +import { + CreateArticleDto, + PreviewContractTemplateDto, + ReplaceArticlesDto, + UpdateArticleDto, + UpdateContractTemplateDto, +} from "./dto/contract-template.dto"; + +@ApiTags("contract-templates") +@Controller("contract-templates") +export class ContractTemplatesController { + constructor(private readonly service: ContractTemplatesService) {} + + // Reads stay open to authenticated staff (the backoffice Templates tab); + // writes are admin-guarded like other freight configuration resources. + + @Get() + @ApiOperation({ summary: "List the six contract document templates" }) + list() { + return this.service.list(); + } + + @Get(":code") + @ApiOperation({ summary: "Get one contract template by code" }) + getByCode(@Param("code") code: string) { + return this.service.getByCode(code); + } + + @Patch(":code") + @FreightAdmin() + @ApiOperation({ summary: "Update template metadata (name, title, recitals, active flag)" }) + update(@Param("code") code: string, @Body() dto: UpdateContractTemplateDto) { + return this.service.update(code, dto); + } + + @Post(":code/preview") + @ApiOperation({ + summary: "Render an HTML preview of the template against mock contract data", + }) + preview( + @Param("code") code: string, + @Body() dto: PreviewContractTemplateDto, + ) { + return this.service.preview(code, dto); + } + + /* ------------------------- article routes ------------------------- */ + + @Put(":code/articles") + @FreightAdmin() + @ApiOperation({ summary: "Replace the full ordered article list (used for reorder)" }) + replaceArticles(@Param("code") code: string, @Body() dto: ReplaceArticlesDto) { + return this.service.replaceArticles(code, dto.articles); + } + + @Post(":code/articles") + @FreightAdmin() + @ApiOperation({ summary: "Add an article to the template" }) + addArticle(@Param("code") code: string, @Body() dto: CreateArticleDto) { + return this.service.addArticle(code, dto); + } + + @Patch(":code/articles/:articleId") + @FreightAdmin() + @ApiOperation({ summary: "Update an article's title or body" }) + updateArticle( + @Param("code") code: string, + @Param("articleId") articleId: string, + @Body() dto: UpdateArticleDto, + ) { + return this.service.updateArticle(code, articleId, dto); + } + + @Delete(":code/articles/:articleId") + @FreightAdmin() + @ApiOperation({ summary: "Remove an article from the template" }) + removeArticle( + @Param("code") code: string, + @Param("articleId") articleId: string, + ) { + return this.service.removeArticle(code, articleId); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.module.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.module.ts new file mode 100644 index 000000000..d2a658ca8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { ContractTemplatesController } from "./contract-templates.controller"; +import { ContractTemplatesRepository } from "./contract-templates.repository"; +import { ContractTemplatesService } from "./contract-templates.service"; +import { ContractTemplate } from "./entities/contract-template.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([ContractTemplate])], + controllers: [ContractTemplatesController], + providers: [ + ContractTemplatesRepository, + ContractTemplatesService, + // Stateless Handlebars renderer reused from src/contracts for previews. + ContractRendererService, + ], + exports: [ContractTemplatesService], +}) +export class ContractTemplatesModule {} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts new file mode 100644 index 000000000..2f4fb0117 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -0,0 +1,31 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { + ContractTemplate, + ContractTemplateCode, +} from "./entities/contract-template.entity"; + +@Injectable() +export class ContractTemplatesRepository extends BaseRepository { + constructor( + @InjectRepository(ContractTemplate) + repository: Repository, + ) { + super(repository); + } + + findByCode(code: ContractTemplateCode): Promise { + return this.repository.findOne({ where: { code } }); + } + + override findAll(): Promise { + return this.repository.find({ order: { code: "ASC" } }); + } + + async saveTemplate(template: ContractTemplate): Promise { + return this.repository.save(template); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts new file mode 100644 index 000000000..0478db102 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts @@ -0,0 +1,66 @@ +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { CONTRACT_TEMPLATE_DEFAULTS } from "../../seed/data/contract-template-defaults"; +import { ContractTemplatesService } from "./contract-templates.service"; +import { ContractTemplatesRepository } from "./contract-templates.repository"; +import { + ContractTemplate, + contractTemplateCodeFor, +} from "./entities/contract-template.entity"; + +function seededTemplate(code: string): ContractTemplate { + const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code)!; + return { + id: "00000000-0000-0000-0000-000000000001", + code: seed.code, + name: seed.name, + description: seed.description, + documentTitle: seed.documentTitle, + whereasClauses: seed.whereasClauses, + articles: seed.articles.map((article, index) => ({ ...article, order: index + 1 })), + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as ContractTemplate; +} + +describe("contractTemplateCodeFor", () => { + it("maps every direction/freight pair to one of the six codes", () => { + expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK"); + expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER"); + expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER"); + expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK"); + expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER"); + }); +}); + +describe("ContractTemplatesService.preview", () => { + const renderer = new ContractRendererService(); + renderer.onModuleInit(); + + const repository = { + findByCode: jest.fn((code: string) => Promise.resolve(seededTemplate(code))), + } as unknown as ContractTemplatesRepository; + + const service = new ContractTemplatesService(repository, renderer); + + it.each(CONTRACT_TEMPLATE_DEFAULTS.map((t) => [t.code] as const))( + "renders a complete mock preview for %s", + async (code) => { + const { html } = await service.preview(code); + expect(html).toContain("Article 1"); + expect(html).toContain("Article 13"); + expect(html).toContain("Abyssinia Trading PLC"); + expect(html).toContain("Annex A — Commercial Schedule"); + // No unrendered handlebars placeholders may leak into the document. + expect(html).not.toContain("{{"); + // Greenish theme applied. + expect(html).toContain("#1b9e7a"); + }, + ); + + it("interpolates {{contractYear}} inside seeded article bodies", async () => { + const { html } = await service.preview("IMPORT_BULK"); + expect(html).toContain(`August 31, ${new Date().getFullYear()}`); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts new file mode 100644 index 000000000..d2aea9bc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -0,0 +1,276 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { randomUUID } from "node:crypto"; + +import { ContractRendererService } from "../../contracts/contract-renderer.service"; +import { getTemplateMeta } from "../../contracts/contract-template.registry"; +import { + ContractDynamicTemplateView, + ContractViewModel, +} from "../../contracts/contract-view-model.builder"; +import { ContractTemplatesRepository } from "./contract-templates.repository"; +import { + CreateArticleDto, + PreviewContractTemplateDto, + ReplaceArticleDto, + UpdateArticleDto, + UpdateContractTemplateDto, +} from "./dto/contract-template.dto"; +import { + CONTRACT_TEMPLATE_CODES, + ContractTemplate, + ContractTemplateArticle, + ContractTemplateCode, + contractTemplateCodeFor, +} from "./entities/contract-template.entity"; + +/** Registry keys used to derive labels for the mock preview per template code. */ +const PREVIEW_TEMPLATE_KEYS: Record = { + IMPORT_BULK: "IMP_BULK_USD_FORWARDING", + EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY", + INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY", + IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY", + EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING", + INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", +}; + +@Injectable() +export class ContractTemplatesService { + constructor( + private readonly repository: ContractTemplatesRepository, + private readonly renderer: ContractRendererService, + ) {} + + async list(): Promise { + const templates = await this.repository.findAll(); + const rank = new Map(CONTRACT_TEMPLATE_CODES.map((code, i) => [code, i] as const)); + return templates.sort( + (a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99), + ); + } + + async getByCode(code: string): Promise { + const template = await this.repository.findByCode(this.assertCode(code)); + if (!template) { + throw new NotFoundException(`Contract template ${code} not found`); + } + return template; + } + + /** + * The active template used when generating a contract document for the given + * direction/freight pair; null when missing or deactivated (the renderer then + * falls back to the built-in generic layout). + */ + async findActiveForContract( + tradeDirection?: string | null, + freightType?: string | null, + ): Promise { + const code = contractTemplateCodeFor(tradeDirection, freightType); + const template = await this.repository.findByCode(code); + return template?.isActive ? template : null; + } + + async update(code: string, dto: UpdateContractTemplateDto): Promise { + const template = await this.getByCode(code); + if (dto.name !== undefined) template.name = dto.name; + if (dto.description !== undefined) template.description = dto.description; + if (dto.documentTitle !== undefined) template.documentTitle = dto.documentTitle; + if (dto.whereasClauses !== undefined) template.whereasClauses = dto.whereasClauses; + if (dto.isActive !== undefined) template.isActive = dto.isActive; + return this.repository.saveTemplate(template); + } + + async addArticle(code: string, dto: CreateArticleDto): Promise { + const template = await this.getByCode(code); + const articles = this.sorted(template.articles); + const article: ContractTemplateArticle = { + id: randomUUID(), + title: dto.title, + body: dto.body, + order: 0, + }; + const index = + dto.position && dto.position <= articles.length ? dto.position - 1 : articles.length; + articles.splice(index, 0, article); + template.articles = this.renumber(articles); + return this.repository.saveTemplate(template); + } + + async updateArticle( + code: string, + articleId: string, + dto: UpdateArticleDto, + ): Promise { + const template = await this.getByCode(code); + const article = template.articles.find((item) => item.id === articleId); + if (!article) { + throw new NotFoundException(`Article ${articleId} not found on template ${code}`); + } + if (dto.title !== undefined) article.title = dto.title; + if (dto.body !== undefined) article.body = dto.body; + template.articles = this.renumber(this.sorted(template.articles)); + return this.repository.saveTemplate(template); + } + + async removeArticle(code: string, articleId: string): Promise { + const template = await this.getByCode(code); + const remaining = template.articles.filter((item) => item.id !== articleId); + if (remaining.length === template.articles.length) { + throw new NotFoundException(`Article ${articleId} not found on template ${code}`); + } + template.articles = this.renumber(this.sorted(remaining)); + return this.repository.saveTemplate(template); + } + + /** Replace the full ordered article list (also how the editor reorders). */ + async replaceArticles( + code: string, + articles: ReplaceArticleDto[], + ): Promise { + const template = await this.getByCode(code); + template.articles = this.renumber( + articles.map((item) => ({ + id: item.id ?? randomUUID(), + title: item.title, + body: item.body, + order: 0, + })), + ); + return this.repository.saveTemplate(template); + } + + /** + * Render the template against a representative mock contract so admins can + * see the final document without touching a real contract. Draft overrides + * allow previewing unsaved editor state. + */ + async preview( + code: string, + overrides?: PreviewContractTemplateDto, + ): Promise<{ html: string }> { + const template = await this.getByCode(code); + + const dynamicTemplate: ContractDynamicTemplateView = { + code: template.code, + name: overrides?.name ?? template.name, + documentTitle: overrides?.documentTitle ?? template.documentTitle, + whereasClauses: overrides?.whereasClauses ?? template.whereasClauses, + articles: overrides?.articles + ? overrides.articles.map((item, index) => ({ + id: item.id ?? randomUUID(), + title: item.title, + body: item.body, + order: index + 1, + })) + : this.sorted(template.articles), + }; + + const view = this.buildMockView(template.code, dynamicTemplate); + return { html: this.renderer.render(view) }; + } + + private buildMockView( + code: ContractTemplateCode, + dynamicTemplate: ContractDynamicTemplateView, + ): ContractViewModel { + const meta = getTemplateMeta(PREVIEW_TEMPLATE_KEYS[code]); + const isBulk = code.endsWith("_BULK"); + const now = new Date(); + + const unitRates = isBulk + ? [ + { label: "Rail transport — per metric ton", unitPrice: 59.4, unit: "ton", currency: "USD" }, + { label: "Origin handling and documentation", unitPrice: 18, unit: "ton", currency: "USD" }, + { label: "Lashing material (when provided by EDR)", unitPrice: 150, unit: "unit", currency: "USD" }, + ] + : [ + { label: "Rail transport — 40ft container", unitPrice: 1916, unit: "container", currency: "USD" }, + { label: "Rail transport — 2 × 20ft containers", unitPrice: 1944, unit: "container", currency: "USD" }, + { label: "Excess tonnage surcharge", unitPrice: 10, unit: "ton", currency: "USD" }, + ]; + + return { + bookingId: "00000000-0000-0000-0000-000000000000", + reference: "EDR/CT/2026/0042", + status: "CONTRACT_READY", + templateKey: PREVIEW_TEMPLATE_KEYS[code], + template: { ...meta, title: dynamicTemplate.name, templateFile: "edr-dynamic.hbs" }, + contractDate: now.toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }), + contractYear: now.getFullYear(), + client: { + companyName: "Abyssinia Trading PLC", + companyAddress: "Bole Sub-city, Woreda 03, H.No 1234, Addis Ababa", + companyLocation: "Ethiopia", + phone: "+251 91 123 4567", + email: "logistics@abyssiniatrading.et", + tinNumber: "0011223344", + vatNumber: "VAT-556677", + fanNumber: "FAN-889900", + businessLicense: "BL/AA/12/345678", + }, + provider: { + name: "Ethio-Djibouti Standard Gauge Railway Share Company", + address: "Nifas Silk Lafto Sub City, Addis Ababa, Ethiopia", + phone: "+251 11 872 0000", + email: "info@edr.gov.et", + tinNumber: "—", + }, + schedule: { + originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", + destinationLabel: "Galaan Multipurpose Port (GMP)", + tradeDirection: code.startsWith("IMPORT") + ? "IMPORT" + : code.startsWith("EXPORT") + ? "EXPORT" + : "DOMESTIC", + freightType: isBulk ? "BULK" : "CONTAINER", + serviceType: "Rail transport and customs clearance", + scheduledDate: "—", + contractType: "GENERAL", + cargoDescription: isBulk ? "Steel billets — 2,800 MT" : "40ft containers — FMCG cargo", + totalWeightVgm: "—", + equipmentReturn: isBulk ? "—" : "With empty return", + hazardousLabel: "No", + firstMilePickupAddress: "—", + lastMileDeliveryAddress: "—", + }, + pricing: { + displayMode: "UNIT_RATES", + unitRates, + currency: "USD", + equipmentReturn: isBulk ? "—" : "With empty return", + originLabel: isBulk ? "Nagad Railway Station" : "SGTD Freight Station", + destinationLabel: "Galaan Multipurpose Port (GMP)", + } as unknown as ContractViewModel["pricing"], + signatures: [], + canSignCustomer: false, + canSignStaff: false, + hasContractDocument: false, + hasCustomerSignature: false, + hasStaffSignature: false, + dynamicTemplate, + }; + } + + private assertCode(code: string): ContractTemplateCode { + const upper = code?.toUpperCase() as ContractTemplateCode; + if (!CONTRACT_TEMPLATE_CODES.includes(upper)) { + throw new BadRequestException( + `Unknown contract template code "${code}". Valid codes: ${CONTRACT_TEMPLATE_CODES.join(", ")}`, + ); + } + return upper; + } + + private sorted(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { + return [...(articles ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + } + + private renumber(articles: ContractTemplateArticle[]): ContractTemplateArticle[] { + return articles.map((article, index) => ({ ...article, order: index + 1 })); + } +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts new file mode 100644 index 000000000..0ea69f262 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -0,0 +1,134 @@ +import { ApiPropertyOptional, ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + MaxLength, + Min, + MinLength, + ValidateNested, +} from "class-validator"; + +export class UpdateContractTemplateDto { + @ApiPropertyOptional({ description: "Display name of the template" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(200) + name?: string; + + @ApiPropertyOptional({ description: "Short description shown on the template card" }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: "Cover-page service title of the generated document" }) + @IsOptional() + @IsString() + @MinLength(3) + @MaxLength(300) + documentTitle?: string; + + @ApiPropertyOptional({ description: "WHEREAS recitals", type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + whereasClauses?: string[]; + + @ApiPropertyOptional({ description: "Whether the template is used for generation" }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateArticleDto { + @ApiProperty({ description: "Article heading (without the Article N prefix)" }) + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @ApiProperty({ + description: + 'Article body. One clause per line; prefix a line with "- " to nest it as a bullet under the previous clause.', + }) + @IsString() + @MinLength(2) + body!: string; + + @ApiPropertyOptional({ description: "1-based position to insert at (appends when omitted)" }) + @IsOptional() + @IsInt() + @Min(1) + position?: number; +} + +export class UpdateArticleDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(200) + title?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MinLength(2) + body?: string; +} + +export class ReplaceArticleDto { + @ApiPropertyOptional({ description: "Existing article id (new id assigned when omitted)" }) + @IsOptional() + @IsString() + id?: string; + + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(200) + title!: string; + + @ApiProperty() + @IsString() + @MinLength(2) + body!: string; +} + +export class ReplaceArticlesDto { + @ApiProperty({ type: [ReplaceArticleDto], description: "Full ordered article list" }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReplaceArticleDto) + articles!: ReplaceArticleDto[]; +} + +/** Optional draft overrides so the editor can preview unsaved changes. */ +export class PreviewContractTemplateDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + documentTitle?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + whereasClauses?: string[]; + + @ApiPropertyOptional({ type: [ReplaceArticleDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReplaceArticleDto) + articles?: ReplaceArticleDto[]; +} diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts new file mode 100644 index 000000000..73729fbb6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -0,0 +1,77 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +/** + * The six canonical contract document templates, one per + * (trade direction × freight type) combination. Contracts store DOMESTIC for + * intercity movements; the template layer labels those INTERCITY to match the + * commercial vocabulary used on the printed documents. + */ +export const CONTRACT_TEMPLATE_CODES = [ + "IMPORT_BULK", + "EXPORT_BULK", + "INTERCITY_BULK", + "IMPORT_CONTAINER", + "EXPORT_CONTAINER", + "INTERCITY_CONTAINER", +] as const; + +export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number]; + +/** + * One dynamic article on a contract template. `body` is plain multiline text: + * each non-empty line renders as a numbered clause; lines prefixed with "- " + * render as bullet points nested under the preceding clause. A single-line + * body renders as an unnumbered paragraph. Handlebars placeholders (e.g. + * {{client.companyName}}, {{contractDate}}, {{contractYear}}, {{reference}}) + * are interpolated against the contract view model at render time. + */ +export interface ContractTemplateArticle { + id: string; + title: string; + body: string; + order: number; +} + +/** Map a contract's stored direction/freight pair onto a template code. */ +export function contractTemplateCodeFor( + tradeDirection?: string | null, + freightType?: string | null, +): ContractTemplateCode { + const direction = + tradeDirection === "IMPORT" + ? "IMPORT" + : tradeDirection === "EXPORT" + ? "EXPORT" + : "INTERCITY"; + const freight = + (freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER"; + return `${direction}_${freight}` as ContractTemplateCode; +} + +@Entity({ schema: "freight", name: "contract_templates" }) +@Index(["code"], { unique: true }) +export class ContractTemplate extends BaseEntity { + @Column({ name: "code", type: "varchar", length: 40, unique: true }) + code!: ContractTemplateCode; + + @Column({ name: "name", type: "varchar", length: 200 }) + name!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + /** Cover-page service line, e.g. "Steel Billet Transportation and Customs Clearance Services". */ + @Column({ name: "document_title", type: "varchar", length: 300 }) + documentTitle!: string; + + /** WHEREAS recitals rendered between the parties block and the articles. */ + @Column({ name: "whereas_clauses", type: "jsonb", default: () => "'[]'" }) + whereasClauses!: string[]; + + @Column({ name: "articles", type: "jsonb", default: () => "'[]'" }) + articles!: ContractTemplateArticle[]; + + @Column({ name: "is_active", type: "boolean", default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index e25c10054..e109d40a1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -350,6 +350,17 @@ export class ContractTransitionService { const updated = await this.contractsService.findById(contractId); if (allDone) { this.notifier.approved(updated); + // Final approval step also generates the contract document from the + // template matching the contract's direction/freight pair. Best-effort: + // a rendering hiccup must not roll back the approval — the document can + // still be generated manually or lazily on view/download. + try { + return await this.generateContract(contractId); + } catch (err) { + this.logger.warn( + `Auto contract generation after final approval failed for ${updated.reference}: ${err}`, + ); + } } return updated; } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 33a547a9f..96bdf22b1 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -16,6 +16,7 @@ import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -81,6 +82,9 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum NotificationsModule, NotificationInboxModule, CompaniesModule, + // Provides the admin-editable contract document templates consumed by + // ContractDocumentViewModelBuilder when rendering contract PDFs. + ContractTemplatesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts new file mode 100644 index 000000000..6fba64010 --- /dev/null +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -0,0 +1,873 @@ +import type { + ContractTemplateArticle, + ContractTemplateCode, +} from "../../modules/contract-templates/entities/contract-template.entity"; + +/** + * Default article packs for the six contract templates, transcribed from the + * signed EDR contract documents (test/contrat_docs). Article bodies use the + * dynamic-article text format: one clause per line, "- " prefix for bullets + * nested under the previous clause, single-line body = plain paragraph. + * Handlebars placeholders ({{client.companyName}}, {{contractDate}}, + * {{contractYear}}, {{reference}}) interpolate at render time. + */ +export interface ContractTemplateSeed { + code: ContractTemplateCode; + name: string; + description: string; + documentTitle: string; + whereasClauses: string[]; + articles: Array>; +} + +const a = (id: string, title: string, body: string): Omit => ({ + id, + title, + body: body.trim(), +}); + +/* ────────────────────────────── IMPORT / BULK ────────────────────────────── */ + +const IMPORT_BULK: ContractTemplateSeed = { + code: "IMPORT_BULK", + name: "Bulk Import Contract", + description: + "Import of bulk cargo (e.g. steel billets) from Djibouti (DMP/Nagad) to Galaan Multipurpose Port with customs clearance and optional last-mile delivery.", + documentTitle: "Bulk Cargo Transportation and Customs Clearance Services", + whereasClauses: [ + "The Client has agreed to engage the Service Provider for transportation and customs clearance services for bulk cargo, including first-mile transport to the railway station at Djibouti, loading at either DMP or Nagad Railway Station (Djibouti), port/rail terminal handling, loading onto the train, railway transport to Galaan Multipurpose Port (GMP) in Ethiopia, unloading at the destination port from train to load directly on truck, onward transportation to the Client's site (excluding truck loading at Djibouti and truck unloading at the Client destination where last-mile service is undertaken by the Service Provider), and all related documentation.", + "The Service Provider has agreed to provide the requested services in accordance with the terms and conditions of this Agreement.", + ], + articles: [ + a( + "objective", + "Objective of the Services", + `The objective of this contract is to provide the Client with integrated logistics services for the transportation of bulk cargo, including: +- First-mile transportation in Djibouti from the Client's designated cargo location to the selected railway station (DMP or Nagad). +- Port handling and loading onto railway wagons. +- Railway transport from DMP and/or Nagad Railway freight station (Djibouti) to Galaan Multipurpose Port. +- Customs clearance in Djibouti and Ethiopia. +- Unloading from train at the destination port to load directly on truck. +- Last-mile delivery by truck to the Client's delivery site where the last-mile service is undertaken by the Service Provider. +The truck loading at Djibouti and the truck unloading at the Client's delivery site shall be the responsibility of the Client.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Provide written/email/electronic instructions specifying the cargo volume and the selected loading station (DMP or Nagad) for each shipment. +Prepare and submit all necessary documents and permits to enable smooth service execution. +Ensure cargo readiness in compliance with specifications (including weight, size, and contour restrictions). +Handle truck loading at Djibouti Free Zone/Old Port/DMP and any other designated cargo location at Djibouti, and truck unloading at the delivery site. +Ensure safety and proper securing of cargo during truck handling. +Submit all required documents necessary for customs clearance and cargo release within one (1) calendar day from the date of request or notification by the Service Provider. +Upon receipt of the wagon allocation list and train schedule from the Service Provider, ensure that the cargo is transferred to the designated loading freight station and made ready for loading within two (2) days prior to wagon arrival. Any delay beyond this period resulting from Client-related issues shall be subject to a charge of USD 56 per wagon per day, or part thereof, until the cargo is made available for loading. +Upon arrival of the train at Galaan Multipurpose Port (GMP), offload cargo from wagons within twenty-four (24) hours of train arrival. Where the Client undertakes last-mile transportation, the Client may arrange sufficient trucks at the time of train arrival to enable direct loading of cargo from wagons to trucks. +In the event the Client is unable to provide trucks for the collection of cargo within twenty-four (24) hours of train arrival, the Service Provider shall have the right to handle and reposition the cargo to any location it deems appropriate, and shall not be held responsible for any loss, shortage, or damage arising from such repositioning. +Any additional handling, re-handling, or repeated loading operations performed by the Service Provider shall be charged as double handling fees at a rate of USD 4 per ton, payable by the Client. +If stored, the full cargo must be collected from the Galaan Multipurpose Port compound within three (3) days from the time of train arrival at the port. +If the Client fails to collect the cargo within the specified period, the Client shall be liable to pay demurrage charges of USD 2 per day per ton, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +Where the last-mile service is provided by the Service Provider, unload the cargo from the truck at the delivery site within the agreed time frame. +Designate authorized representatives (with valid power of attorney) for handover at origin and destination. +Settle demurrage payments within ten (10) calendar days from the date the Service Provider issues a claim. +Pay the Service Provider one hundred percent (100%) of the contract price in advance for each train set in accordance with the pricing article of this Agreement. +Contact the Service Provider to obtain confirmation prior to booking and proceeding with payment.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Provide first-mile transportation in Djibouti from the Client's designated location to the selected railway station (DMP or Nagad). +Carry out port handling and loading onto railway wagons. +Provide railway transportation from DMP/Nagad (Djibouti) railway freight station to Galaan Multipurpose Port. +Perform unloading at Galaan Multipurpose Port (GMP) to load directly on truck. +Perform customs clearance in Djibouti and Ethiopia, including border station procedures. +Prepare and submit all required transport documentation. +Provide cargo insurance coverage for each supplied wagon. +Notify the Client of train schedules, wagon numbers, and expected arrival times in advance.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "liability", + "Liabilities Related to Damages and Losses", + `The Service Provider shall be fully responsible for any loss, shortage, or damage to cargo that occurs after it has been taken over until delivery to the Client's delivery site. +Compensation shall be based on the market value of the cargo, in accordance with applicable laws.`, + ), + a( + "pricing", + "Contract Price and Payment Terms", + `Rail transport to Galaan Multipurpose Port: USD 59.4 per metric ton. +Djibouti handling (first-mile, port handling and loading, and documentation): USD 18 (eighteen) per metric ton for cargo from the Free Zone; USD 20 (twenty) per metric ton for cargo from the Old Port or DMP. +Lashing materials shall be charged at USD 150 (one hundred fifty) per wagon and wood at USD 50 (fifty) per wagon when provided by the Service Provider; the provision continues until the cargo reaches and is fully unloaded at the designated destination station. +Each wagon shall be loaded up to a maximum of seventy (70) metric tons; for billing purposes one full wagon shall be deemed equivalent to this volume. +The price for last-mile delivery shall be determined once the cargo departs from the loading point and shall be communicated to the Client by official email upon the Client's request. +Payments shall be made 100% in advance in USD.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents form part of this contract: +- This Contract Agreement. +- Any amendments made to this Agreement. +- Minutes of negotiation (if any).`, + ), + a( + "documentation", + "Documentation Requirements", + `The Service Provider shall deliver the following to the Client: +- Freight Carriage Acceptance Sheet of the Addis Ababa–Djibouti Railway. +- Notice of transportation and miscellaneous charges. +- Summary of payment request as per the agreed tariff, if required.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider shall certify the taking over of goods in the Freight Carriage Acceptance Sheet. +This document shall serve as prima facie evidence of receipt of the cargo. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "termination", + "Termination of Contract", + `This contract may be terminated: +- By mutual consent. +- Upon completion of the agreed contract period or cargo volume. +- For breach of fundamental provisions, with one-week prior written notice.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `This Agreement becomes effective on the date it is signed by both parties.`, + ), + a( + "duration", + "Duration", + `The contract is valid until August 31, {{contractYear}} from the date of effectiveness, extendable by mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall first be settled amicably. +If unresolved, disputes shall be referred to the competent Federal Court of Ethiopia in Addis Ababa. +The governing law shall be the laws of the Federal Democratic Republic of Ethiopia.`, + ), + ], +}; + +/* ────────────────────────────── EXPORT / BULK ────────────────────────────── */ + +const EXPORT_BULK: ContractTemplateSeed = { + code: "EXPORT_BULK", + name: "Bulk Export Contract", + description: + "Export of bulk cargo (e.g. livestock) by railway from Ethiopian loading stations to Nagad railway freight yard, Djibouti.", + documentTitle: "Bulk Cargo Transportation Service by Railway", + whereasClauses: [ + "The Client has agreed to deliver bulk cargo to the Service Provider for transport from the agreed Ethiopian loading station to Nagad railway freight yard using the Addis Ababa–Djibouti railway line.", + "The Service Provider has agreed to provide the service to transport the bulk cargo from the agreed loading station to Nagad railway freight yard.", + ], + articles: [ + a( + "objective", + "Objective of the Service", + `To undertake the railway transportation of bulk cargo from the agreed Ethiopian loading station to Nagad railway freight yard.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written instruction to the Service Provider to transport a minimum of one wagon of cargo; the wagon request shall be made at least five (5) days in advance for each wagon. +Maintain detailed information incorporating type, weight, and destination of the cargo ready for shipment, and notify the Service Provider or its nominated agent by notice, email, or fax. +Prepare the necessary documents and facilities to make the cargo ready for transport. +Note the allowable transport period of the cargo: the maximum time range during which the goods maintain their condition without any problem. The allowable transport period must be at least two (2) days longer than the delivery period. +Ensure cargo is properly loaded and fastened in the wagons, provide the necessary lashing and barriers for loading as per the instruction of the departure station, and bear responsibility for the condition of the cargo during the transport period. +Supply the necessary provisions for the cargo for each wagon and assign a responsible person to travel with the train to check the status of the cargo during transport, where the nature of the cargo so requires. +Supply the minimum amount of cargo available for at least one wagon. +Execute loading, lashing, and preparing barriers on wagons at the loading station and provide the complete documents/bill to the Service Provider within one (1) calendar day. +For each extra calendar day used for loading cargo and completing documents at the loading station, pay the wagon-occupied fee per the pricing article; the fee shall be paid within ten (10) calendar days from the date the Service Provider claims it, failing which compensation is payable calculated on the basis of the Commercial Bank of Ethiopia interest rate for the delay period. +Be responsible for safety matters, and indemnify and hold the Service Provider harmless against all consequences resulting from accidents arising from or associated with the loading and unloading process. +Execute and cover the cost of loading and unloading of cargo at both the loading station and Nagad railway freight yard. +Follow up that the cargo is loaded and unloaded on time. +Delegate representatives at both ends to consign and receive cargo with signature and stamp. Representatives shall hold a duly signed and stamped power of attorney and shall produce their ID or passport when consigning or receiving the cargo. +Prepare the necessary facilities to take over the transported cargo at Nagad freight yard upon arrival by issuing handover documents. +Take the transported cargo out of the wagons at Nagad freight yard within one (1) calendar day starting from the day following the notice of arrival. +Pay the wagon-occupied fee per the pricing article for delays of more than one (1) calendar day at Nagad railway freight yard due to the fault of the Client in resolving customs or third-party claims or any other causes. +After the wagon list is submitted to the Client, if a wagon is not loaded due to the fault of the Client, pay 100% of the transportation price per wagon for each unloaded wagon. +Pay the Service Provider 100% of the contract price in advance for each wagon.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Provide the list and identification numbers of wagons with sequence and locomotives at least twenty-four (24) hours in advance to the Client, with any correction at least twelve (12) hours before arrival at destination. +Transport the cargo from the loading station to Nagad railway freight yard. +Provide safe transportation of the cargo throughout the transit. +Present customs clearance documents and mobilize the rolling stock as needed. +Provide the wagons assigned for the freight at the agreed place and time, and follow up that the cargo is loaded on time. +Transport and deliver the cargo taken over, in the condition received, within two (2) calendar days to Nagad railway freight yard. +Where a wagon carrying cargo stops due to accident or mechanical problem, promptly notify the nearby customs station, police office, and the Client. A wagon stopped in Ethiopia due to mechanical defect shall be maintained within four (4) calendar days; within Nagad (Djibouti) territory within twelve (12) calendar days. In case of accident where the problem cannot be solved within one (1) calendar day and the wagon is not operational, the Service Provider shall have the cargo carried and delivered by another wagon, and shall provide an accident or defect report issued by the local police office regarding the sustained damage. +Buy a cargo liability insurance policy for each supplied wagon. +Provide wagon cleaning service and charge the cleaning fee based on actual expenditure. +If the Client fails or refuses to receive the cargo beyond the allowable transport period, the Service Provider has the right to handle the cargo. +Neither party shall be liable for any indirect or consequential loss sustained by the other in connection with this Agreement.`, + ), + a( + "force-majeure", + "Force Majeure", + `The parties have no obligation to pay demurrage or any other compensation if they have failed to discharge their obligations due to force majeure. +Force majeure shall be deemed to exist when the contract is not performed due to any event beyond the reasonable control of a party which prevents that party from complying with its obligations under this Agreement, including but not limited to: +- Acts of God (such as, but not limited to, fires, explosions, earthquakes, drought, tidal waves, and floods). +- War, hostilities (whether war is declared or not), invasion, acts of foreign enemies, mobilization, requisition, or embargo. +- Rebellion, revolution, insurrection, military or usurped power, or civil war. +- Contamination by radioactivity from any nuclear fuel or nuclear waste. +- Riot, commotion, strikes, go-slows, lockouts, or disorder. +- Acts of terrorism. +A party wishing to claim protection in respect of a force majeure event shall, as soon as possible following the occurrence or commencement of the event, notify the other party of its nature and expected duration, and shall thereafter keep the other party informed until it is able to perform its obligations under this Agreement.`, + ), + a( + "pricing", + "Contract Price and Terms of Payment", + `The price of bulk cargo transportation from the loading station to Nagad shall be USD 696 (six hundred ninety-six) per wagon. +Payment for transport services shall be made in Birr based on the selling price of USD to Birr on the date of payment set by the Commercial Bank of Ethiopia. +If there is an increment or decrement of the USD exchange rate to Birr between the date of payment and the date the wagon/train number is provided to the Client, either the Client shall make the additional payment to the Service Provider or the Service Provider shall refund the difference from the initial payment to the Client. +The cost of loading at the loading station and unloading at Nagad shall be covered by the Client and is not part of this contract agreement. +The Client shall pay 100% of the contract price in advance. +The Client shall pay a demurrage fee for occupied wagons as follows: +- Wagons occupied between 1 and 3 days: USD 193 per wagon per day. +- Wagons occupied between 4 and 7 days: USD 290 per wagon per day. +- Wagons occupied 8 days and above: USD 590 per wagon per day. +Demurrage payment shall be made in Birr based on the selling price of USD to Birr set by the Commercial Bank of Ethiopia on the date of the demurrage occurrence.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents shall constitute the contract between the Client and the Service Provider: +- Amendments made to this contract (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `The following documents shall be delivered to the Client upon request for settlement: +- Consignment Note (cargo handover document to the Client). +- Summary of payment request of the Service Provider prepared as per the agreed tariff.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over of the goods on the duplicates of the consignment note in an appropriate manner and return the duplicate to the Client. +A consignment note shall be prima facie evidence of the receipt of the goods by the Service Provider and of the kind, number, and weight of the goods.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- Upon mutual consent of the parties. +- Upon completion of the contract period. +- If either or both parties breach a fundamental provision of the contract, upon prior legal notice delivered by either party.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract shall come into full force and effect on the date when all of the following are accomplished: +- The contract is signed by the Client and the Service Provider. +- The Service Provider has received the advance payment of 100% of the contract price for each train set of cargo.`, + ), + a( + "cargo-amount", + "Cargo Amount", + `The minimum cargo to be transported shall be one wagon.`, + ), + a( + "duration", + "Duration of Contract", + `The contract shall last for three (3) months starting from the date of contract signing, with possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `If a dispute arises between the parties, they shall exert efforts to settle their differences amicably. +If the parties fail to settle their disputes amicably, the case shall be taken to the competent Federal Court of law presiding in Addis Ababa. +The governing law shall be the laws of the Federal Democratic Republic of Ethiopia.`, + ), + ], +}; + +/* ──────────────────────────── INTERCITY / BULK ───────────────────────────── */ + +const INTERCITY_BULK: ContractTemplateSeed = { + code: "INTERCITY_BULK", + name: "Bulk Intercity Contract", + description: + "Domestic (intercity) bulk cargo transportation by railway between Ethiopian freight yards, e.g. Dire Dawa to Sebeta.", + documentTitle: "Bulk Cargo Transportation Service by Railway (Intercity)", + whereasClauses: [ + "The Client has requested the Service Provider to transport bulk cargo between the agreed Ethiopian railway freight yards using the Ethio–Djibouti Railway.", + "The Service Provider has accepted the Client's request to render the said transportation service.", + ], + articles: [ + a( + "objective", + "Objective of the Contract", + `The Service Provider shall undertake the railway transportation of bulk cargo from the agreed origin railway freight yard to the agreed destination railway freight yard.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Provide written instructions to the Service Provider to transport a minimum of sixteen (16) wagons of cargo per consignment. +Prepare all necessary documents, including laboratory tests from the pertinent organ and off-taking contract where applicable, and the facilities required to sign the contract and make the cargo ready for transport. +Assign representatives at the origin yard and other stations, as required, to hand over the cargo to the Service Provider and handle transit clearance if required. +Transport the cargo to the designated loading points at the origin yard. +Be responsible for cargo handling: loading at the origin yard and unloading at the destination yard, in accordance with the standards set by the EDR operations and technical terms. +Make advance payment to the Service Provider for services in accordance with the payment terms and conditions of this contract. +Follow up to ensure that the cargo is loaded and unloaded on time. +Delegate representatives at the cargo destination to immediately receive the transported cargo. +Ensure representatives are duly authorized with a power of attorney, signed and stamped by the Client, and present valid identification (ID or passport) when consigning or receiving cargo. +Maintain detailed information including item, weight, and destination of the cargo, and communicate the same to the Service Provider or its nominated agent via written notice, email, or fax. +Prepare the necessary facilities to immediately take over the transported cargo at the destination upon arrival and provide sufficient trucks at the destination freight yard for unloading from railway wagons. +Upon arrival of the train/wagon at the unloading site, sign the train arrival confirmation sheet to acknowledge the arrival time. +Inspect the loaded wagons jointly with the Service Provider and EDR at the loading yard, and again with the customs agent (if required) and the Service Provider at the destination yard. +After receiving the cargo, sign the Freight Carriage Acceptance Sheet (copies II, III, and IV) immediately to confirm delivery. +Compensate the Service Provider or any third party for actual loss or damage caused to persons, property, or wagons during unloading where such damage is attributable to the Client's fault. +Each consignment (train) shall be granted three (3) hours of free time at the loading station and one (1) day at the unloading station. For each additional 3 hours of loading or parking the Client shall pay ETB 5,000 (five thousand) per wagon, and for each additional day of unloading ETB 5,000 (five thousand) per wagon per day. +Bear demurrage charges of ETB 5,000 (five thousand) per wagon per 3 hours for delays exceeding three (3) hours at any station resulting from the Client's failure to resolve customs or third-party claims. +Pay 100% of the transport price in advance. Any additional charges or fees shall be paid within ten (10) calendar days after submission of the Service Provider's payment request.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Provide the necessary train(s) to execute the transportation service under this contract, and furnish the Client with the list and identification numbers of wagons and locomotives at least 24 hours in advance, with corrections (if any) communicated at least 12 hours before the expected time of arrival at destination. +Provide pre-arrival notification including the train number to the discharging terminal and customs at least 24/12 hours before train arrival. +Transport the cargo from origin to destination within two (2) days from completion of loading (time counting starts upon completion of documentation and loading). +Provide safe transportation of the cargo throughout transit. +Deliver the cargo to the Client at the destination railway freight yard in the same condition as received. +Purchase a cargo liability insurance policy for each wagon transported.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable due to a force majeure event. +For the purposes of this contract, force majeure shall mean any unforeseeable event or circumstance beyond the reasonable control of the affected party which absolutely prevents the performance of the contract, including but not limited to natural disasters, war, civil commotion, strikes, government actions, epidemics, or interruption of railway operations due to accidents or infrastructure failure. +The affected party shall notify the other party in writing within a reasonable period not exceeding two (2) hours after the occurrence of the force majeure event, providing evidence and details of the impact on performance and the mitigating steps taken.`, + ), + a( + "liability", + "Liabilities Related to Damages and Losses", + `The Service Provider will be responsible for any loss, shortage, or damage occurring to the cargo it has received.`, + ), + a( + "pricing", + "Contract Price", + `The price for transporting cargo from the origin freight yard to the destination freight yard shall be USD 400 (four hundred) per wagon. +Each wagon shall be loaded with a maximum of 70 (seventy) metric tons. +Payment for transport services may be made in Ethiopian Birr, based on the Commercial Bank of Ethiopia's official selling exchange rate of USD to Birr on the date of payment. +If the exchange rate changes between the payment and the wagon assignment date, payment adjustments will be made accordingly. +The contract price shall include the cost of railway transportation from the origin freight yard to the destination freight yard. +Excluded cost: cargo handling (loading and unloading) is not included in the contract price and shall remain the sole responsibility of the Client.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents shall constitute the contract between the Client and the Service Provider: +- Amendments made to this contract (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `The following documents shall be delivered to the Client by the Service Provider to collect and settle payment: +- Freight Carriage Acceptance Sheet of the Ethio-Djibouti Railway. +- Notice of collecting transportation and miscellaneous charges of the Ethio-Djibouti Railway (if any). +- Summary of payment request of the Service Provider prepared as per the agreed tariff. +- Railway Waybill.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over of the goods on copy III (kept by the consignee for future reference) of the Freight Carriage Acceptance Sheet of the Ethio-Djibouti Railway in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the goods by the Service Provider and of the kind, number, and weight of the goods.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- Upon mutual consent of the parties. +- Upon completion of the contract period or amount of cargo, whichever comes first. +- If either or both parties breach a fundamental provision of the contract, upon one-week prior legal notice delivered by either party.`, + ), + a( + "duration", + "Duration of Contract", + `The contract duration shall be three (3) months from the date of effectiveness of the contract, with possible extension upon mutual agreement of the parties.`, + ), + a( + "disputes", + "Settlement of Disputes", + `If a dispute arises between the parties, they shall exert efforts to settle their differences amicably. +If the parties fail to settle their dispute amicably, the case shall be taken to the competent Federal Court of law presiding in Addis Ababa. +The governing law shall be the laws of the Federal Democratic Republic of Ethiopia.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract shall come into full force and effect on the date when the contract is signed by the parties and witnesses.`, + ), + ], +}; + +/* ──────────────────────────── IMPORT / CONTAINER ─────────────────────────── */ + +const IMPORT_CONTAINER: ContractTemplateSeed = { + code: "IMPORT_CONTAINER", + name: "Container Import Contract", + description: + "Import container transport by railway from SGTD (Djibouti) to Dire Dawa, Modjo dry port, or Galaan Multipurpose Port, with empty-container return.", + documentTitle: "Import Container Transport Service by Railway", + whereasClauses: [ + "The Client has requested and agreed to the transportation of container cargo from SGTD railway freight station at Djibouti to Dire Dawa, Modjo dry port, and Galaan Multipurpose Port (GMP), and the return of empty containers from Dire Dawa, Modjo dry port, and Galaan Multipurpose Port (GMP) to SGTD railway freight station using the Addis Ababa–Djibouti railway line.", + "The Service Provider has agreed to transport the container cargo as per the terms of this contract.", + ], + articles: [ + a( + "objective", + "Objective and Scope of the Services", + `To provide railway transportation services for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port, and/or Galaan Multipurpose Port (GMP), and empty container return from Dire Dawa, Modjo dry port, and Galaan Multipurpose Port (GMP) to SGTD. +The scope of the services comprises: +- Railway transport service. +- Cargo handling at Galaan Multipurpose Port (GMP).`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written/email/electronic shipment instructions to the Service Provider for transportation of container cargo from SGTD to Dire Dawa, Modjo dry port, and/or Galaan Multipurpose Port (GMP). +Prepare all necessary documents and facilities for shipment. +Ensure the following minimum supply of containers per shipment based on the loading terminal and destination: +- Minimum of twenty-five (25) 40ft containers or fifty (50) 20ft containers to Modjo dry port. +- Minimum of ten (10) 40ft containers or twenty (20) 20ft containers to Dire Dawa dry port. +- Minimum of one (1) 40ft container or two (2) TEU to Galaan Multipurpose Port (GMP). +One flat wagon must carry either one 40ft container or two 20ft containers. +If two 20ft containers are loaded on one flat wagon, their weight difference must not exceed 10 tons. +Ensure timely loading and unloading of cargo. +Assign representatives at both ends to oversee container handover and ensure the necessary arrangements for cargo reception at the destination upon arrival. +Maintain and provide detailed cargo information (type, weight, destination, etc.). +Be responsible for cargo handling, loading, and unloading of both empty and full containers at Modjo, Dire Dawa dry port, and SGTD. +Book wagons at least five (5) days in advance. +Ensure containers are ready one day before the planned loading date. +Submit all required documents to the Djibouti Nagad station at least 24 hours in advance before starting to load. Failure to submit the documents within the stipulated time shall result in the following demurrage charges, calculated as a percentage of the booked wagon price: +- Delay of up to twelve (12) hours: 20% of the booked wagon price. +- Delay exceeding twelve (12) hours but not more than one (1) day: 50% of the booked wagon price. +- Delay of more than one (1) day: 100% of the booked wagon price. +Collect the full container from Galaan Multipurpose Port (GMP) within three (3) calendar days from the day following the arrival notice. +If the Client fails to collect the container within the specified period, the Service Provider shall have the right to reposition the container to any location it deems appropriate; in such case, the Service Provider shall not be held responsible for any damage or loss arising from such repositioning. +If the Client fails to collect the container from Galaan Multipurpose Port within the specified period, the Client shall be liable to pay demurrage charges of 15 USD per day per 20ft container and 27 USD per day per 40ft container, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +In the event the Client fails to collect the container(s) within the specified period, the Client shall be liable to pay double handling charges of 27 USD per 20ft container per handling or 40 USD per 40ft container per handling, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +If empty containers cannot be offloaded from the train upon arrival at SGTD due to any Client-related issue, the Client shall be liable for the applicable penalty charges. +Penalty charges for delay at SGTD/Nagad upon train arrival: 20 USD per day per 20ft container; 33 USD per day per 40ft container. +Collect the containers within one day at Dire Dawa and Modjo dry port, or make the necessary payment to the dry port as per the standard of the dry port. +For returning empty containers, deliver to Dire Dawa dry port, Modjo dry port, or Galaan Multipurpose Port. +Once the empty containers are returned from the Client's premises and stored at Dire Dawa/Modjo dry port while awaiting train allocation for return to SGTD, any demurrage and/or storage charges incurred from the dry port thereafter shall not be the responsibility or liability of the Service Provider; the Client shall be solely responsible for settling such charges. +Provide clean empty containers that meet SGTD standards. If the port refuses to take over an empty container because of inside cleanliness problems, additional cleaning costs incurred due to non-compliance will be borne by the Client. +Ensure containers are structurally intact and meet weight distribution requirements. +Prohibited cargo: cargo covered with tarpaulin is not allowed due to safety risks. +Notify the Service Provider forty-eight (48) hours in advance before wagon booking if transporting hazardous or valuable goods. +If a booked wagon is not loaded due to Client-related issues, including but not limited to a damaged container, missing lock, unpaid demurrage, port system errors, or incomplete documentation and submission, the Client shall be charged 100% of the total price of the reserved wagon. +Refund requests before wagon booking require an official request, with a 3% administrative fee deducted. If the refund is due to the Service Provider, the Client shall receive a full refund. Refunds not requested and completed within six months shall be considered waived. +Pay 100% of the transportation fee in advance for each train set. +Settle additional penalties due to non-compliance within ten (10) days of invoice issuance. +Late payment incurs a penalty of an additional 10%.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Assign the necessary voyage based on the operational schedule and cargo demand, and notify the train schedule 48 hours in advance. +Provide a list of wagons/voyage or train number 24 hours in advance and update corrections 12 hours before arrival. +Provide safe transportation of the containers. +Deliver the cargo within two (2) days after train departure, provided that all required documents are submitted on time and no unforeseen circumstances or events occur. +Return empty containers from Dire Dawa, Modjo, and Galaan Multipurpose Port to SGTD within seven (7) calendar days of receipt. +In the event of export cargo operations, the Service Provider may prioritize the loading of export containers during the loading of empty containers and the unloading of import containers from the train at Galaan Multipurpose Port. +The Service Provider reserves the right to refuse the transportation of any cargo classified as dangerous goods. +If any operational, technical, or mechanical problem occurs throughout the transit, notify customs and arrange cargo transfer within 4 days if the incident occurs in Ethiopia, or within 6 days if it occurs in Djibouti. +Provide accident or defect reports if needed. +Buy cargo liability insurance for each wagon.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "pricing", + "Contract Price and Terms of Payment", + `From SGTD to Dire Dawa dry port, the rate is USD 919 per one 40ft or USD 942 per two 20ft containers with empty return; USD 762 per one 40ft or USD 780 per two 20ft containers without empty return. +From SGTD to Modjo, the rate is USD 1,781 per one 40ft or USD 1,808 per two 20ft containers with empty return, and USD 1,507 per one 40ft or two 20ft containers without empty return. +From SGTD to Galaan Multipurpose Port, the rate is USD 1,916 per one 40ft or USD 1,944 per two 20ft containers with empty return, and USD 1,676 per one 40ft or USD 1,690 per two 20ft containers without empty return. +If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. +Gross weight shall be the total sum of cargo, packing, and container tare weight. +Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. +The price of loading and unloading and container handling at Modjo, Dire Dawa dry port, and SGTD container railway freight yard is not part of this contract; it is the Client's responsibility. +Additional costs (if applicable): +- Last-mile delivery service by truck from Galaan Multipurpose Port or Modjo to Addis Ababa or Modjo and surrounding areas shall incur an additional cost, fully covered by the Client. +- For clients utilizing EDR's last-mile logistics services, the applicable charges shall vary based on the cargo movement route. +- The charge for last-mile delivery from Galaan Multipurpose Port and Modjo dry port shall be communicated to the Client by the Service Provider after receiving the necessary details regarding the cargo destination, type, and weight. +All payments shall be made one hundred percent (100%) in advance in United States Dollars (USD).`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents constitute this contract: +- Amendments (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `Equipment Interchange Receipt of SGTD, Railway Waybill, Container Carriage Acceptance Sheet, and incidental charges (if any). +Payment summary as per the agreed contract price (if required).`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over and handing over of the container cargo on the Freight Carriage Acceptance Sheet of EDR in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the container cargo by the Service Provider and of the kind, number, and weight of the goods. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "amendment", + "Amendment", + `This contract can be amended by mutual agreement. +Notwithstanding the above, the Service Provider may revise transport tariffs due to operational and regulatory changes by providing at least five (5) working days' prior written notice to the Client.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- By mutual agreement. +- Upon completion of the contract period or agreed cargo shipments. +- If either party breaches fundamental terms.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract is valid once signed by both parties and witnesses.`, + ), + a( + "duration", + "Contract Period", + `Valid until August 31, {{contractYear}}, with a possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall be settled amicably. +If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`, + ), + ], +}; + +/* ──────────────────────────── EXPORT / CONTAINER ─────────────────────────── */ + +const EXPORT_CONTAINER: ContractTemplateSeed = { + code: "EXPORT_CONTAINER", + name: "Container Export Contract", + description: + "Export container transport, freight forwarding, and customs clearing from Galaan Multipurpose Port or Modjo dry port to SGTD container freight station (Djibouti).", + documentTitle: "Export Container Transport, Freight Forwarding and Customs Clearing Service", + whereasClauses: [ + "The parties have agreed on the following services: rail transport, customs clearance, transit work, freight forwarding, and handling of container cargo.", + ], + articles: [ + a( + "objective", + "Objective and Scope of the Services", + `Customs clearance (Ethiopia side): +- Processing of export declarations. +- Coordination with the Ethiopian Customs Authority for clearance. +- Ensuring compliance with all export regulations. +Rail transport: +- Transportation of containers from Galaan Multipurpose Port (GMP) or Modjo dry port to SGTD container freight station. +Djibouti transit and handling: +- Customs clearance in Djibouti. +- Coordination with Djibouti port and transit authorities. +- Freight forwarding and last-mile facilitation as required. +Excluded costs: +- Shore handling. +- Shifting of containers from SGTD to DMP or DMP to SGTD port.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written/email instructions to the Service Provider for transportation of containers from Galaan Multipurpose Port (GMP) and/or Modjo to Djibouti. +Supply a minimum of two (2) 20ft containers (or an equivalent load to fill one flat wagon). +Submit all forwarding service booking requests at least seventy-two (72) hours prior to the scheduled train departure and no later than 7 days before the vessel cut-off time, whichever is applicable. +For clients requiring first-mile service, submit a first-mile service request notice no less than seventy-two (72) hours in advance. +Complete and submit accurate export documents as per the request of the Service Provider; payment must be submitted at least 36 hours before train departure. +Deliver all cargo to the designated loading port or freight station at least three (3) hours prior to the scheduled train loading time. +Failure to meet the stated deadlines may result in cancellation of the booking and transfer arrangements; any resulting delays, penalties, or additional costs shall be the sole responsibility of the Client. +If the Client fails to deliver the container, fails to provide the requested documents for completing export documents as instructed above, or cancels after wagon reservation, the Client shall pay USD 150.00 per wagon as a penalty, after notification. +Containers must have four (4) undamaged corners. +One flat wagon must carry either one 40ft container or two 20ft containers. +If two 20ft containers are loaded on one flat wagon, their weight difference must not exceed 10 tons. +The gross weight of the container must not exceed the standard loading capacity indicated on the container; the Client is responsible for ensuring full compliance with the maximum allowable load. +Prohibited cargo: cargo covered with tarpaulin is not allowed due to safety risks. +If the cargo to be transported is dangerous and/or valuable goods, notify the Service Provider 48 (forty-eight) hours before the wagon booking for further discussion and decision. +Refund requests before wagon booking require an official request, with a 3% administrative fee deducted. If the refund is due to the Service Provider, the Client shall receive a full refund. Refunds not requested and completed within six months shall be considered waived. +Any delay caused by missing or incorrect documents shall be the Client's responsibility. +100% of the transportation and customs clearance fee must be paid in advance.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Complete gate pass processing and submit to Nagad Station for each shipment within eighteen (18) hours after train departure. +The Service Provider reserves the right to refuse the transportation of any cargo classified as dangerous goods. +Maintain cargo liability insurance for railway transport; additional insurance for port handling or last-mile transport shall be the Client's responsibility. +The Service Provider is not liable for customs penalties or demurrage due to delays beyond its control. +Notify the Client immediately, in writing, of any delays, port issues, or customs holds. +The Service Provider shall not be liable for: +- Inherent defects of the cargo. +- Improper packing or loading conducted by the Client. +- Customs-related delays. +- Delays caused by force majeure events.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "pricing", + "Pricing and Payment Terms", + `Railway transportation charges from GMP to SGTD: USD 819 (eight hundred nineteen) per 40ft container; USD 834 (eight hundred thirty-four) per two (2) 20ft containers. +Railway transportation charges from Modjo to SGTD: USD 725 (seven hundred twenty-five) per 40ft container; USD 725 (seven hundred twenty-five) per two (2) 20ft containers. +Where the total cargo weight exceeds fifty (50) metric tons per two (2) 20ft containers, an additional charge of USD 10 (ten) shall apply for each excess metric ton. +Freight forwarding and customs clearance charges from GMP to SGTD: USD 540 (five hundred forty) per 40ft container; USD 349 (three hundred forty-nine) per 20ft container. +Freight forwarding and customs clearance charges from Modjo to SGTD: USD 569 (five hundred sixty-nine) per 40ft container; USD 389 (three hundred eighty-nine) per 20ft container. +For consolidated containers containing more than one (1) shipping document, the first document shall be included under the agreed contract rate; any additional document within the same container shall be subject to an extra charge of USD 50 per document. +Payment must be supported by an official receipt before cargo departs from Galaan Multipurpose Port/Modjo. +If the Client uses PIL Shipping Line, any local charge incurred will be covered by the Client as per the invoice issued by the shipping line. +If storage or demurrage occurs due to Client-related issues (delay in document submission, payment delay, or any other Client-related reason), the Client shall pay the corresponding charges; charges apply per day after the free storage period, based on the invoice and SGTD tariff. +During export season, EDR may provide seasonal export support through the facilitation of empty containers. +Additional costs (if applicable): +- First-mile delivery service by truck within Addis Ababa or Modjo and surrounding areas, originating from warehouses or any other places designated by the Client, shall incur an additional cost fully covered by the Client. +- For clients utilizing EDR's first- or last-mile logistics services, the applicable charges shall vary based on the cargo movement route. +- The charge for first-mile delivery to Galaan Multipurpose Port and Modjo dry port shall be communicated to the Client by the Service Provider after receiving the necessary details regarding the cargo origin, type, and weight. +- For any vessel outbound charges, IMO charges, or other fees not included in the port handling payment, the Service Provider shall request the Client to settle the required amount based on the official receipt issued by the port or the shipping line. +Payment terms: +- All charges, including rail transport and customs clearance charges, remain 100% payable in advance. +- Payments shall be calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date the wagon or train number is provided. +- If the exchange rate changes between the payment and the wagon assignment date, adjustments will be made accordingly. +- Any additional costs incurred due to customs issues or port delays shall be borne by the Client and paid based on actual costs, supported by official receipts, within 10 days. +- Late payment incurs a penalty of 10%.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents shall constitute the contract between the Client and the Service Provider: +- Any amendments made to this contract (if applicable). +- This Contract Agreement. +- Final minutes of negotiation (if applicable). +In the event of any discrepancy between these documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `Consignment Note (cargo handover document). +Payment summary prepared as per the agreed tariff, if required.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over and handing over of the container cargo on the Freight Carriage Acceptance Sheet of EDR in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the container cargo by the Service Provider and of the kind, number, and weight of the goods. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "amendment", + "Amendment", + `This contract can be amended by mutual agreement. +Notwithstanding the above, the Service Provider may revise transport tariffs due to operational and regulatory changes by providing at least five (5) working days' prior written notice to the Client.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- By mutual agreement. +- Upon completion of the contract period or agreed cargo shipments. +- If either party breaches fundamental terms. +If terminated for cause, the terminating party must issue a 15-day written notice specifying the breach and allow an opportunity to cure, if applicable.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract is valid once signed by both parties and witnesses.`, + ), + a( + "duration", + "Contract Period", + `Valid until August 31, {{contractYear}}, with a possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall be settled amicably. +If amicable settlement fails, disputes shall be submitted to the Federal Court located in Addis Ababa. +The signatories confirm that they are fully authorized to sign and execute this Contract Agreement; the power of attorney of the signatories for the parties is enclosed with this contract agreement.`, + ), + ], +}; + +/* ─────────────────────────── INTERCITY / CONTAINER ───────────────────────── */ + +const INTERCITY_CONTAINER: ContractTemplateSeed = { + code: "INTERCITY_CONTAINER", + name: "Container Intercity Contract", + description: + "Domestic (intercity) container transport by railway between Ethiopian terminals — Galaan Multipurpose Port, Modjo dry port, and Dire Dawa — including empty repositioning.", + documentTitle: "Intercity Container Transport Service by Railway", + whereasClauses: [ + "The Client has requested and agreed to the transportation of container cargo between the agreed Ethiopian railway terminals (Galaan Multipurpose Port (GMP), Modjo dry port, and Dire Dawa), including the repositioning of empty containers between those terminals, using the Addis Ababa–Djibouti railway line within Ethiopia.", + "The Service Provider has agreed to transport the container cargo as per the terms of this contract.", + ], + articles: [ + a( + "objective", + "Objective and Scope of the Services", + `To provide domestic railway transportation services for 40ft and/or 20ft full containers between the agreed Ethiopian terminals (Galaan Multipurpose Port (GMP), Modjo dry port, and Dire Dawa), and the repositioning of empty containers between those terminals. +The scope of the services comprises: +- Railway transport service between the agreed origin and destination terminals. +- Cargo handling at Galaan Multipurpose Port (GMP).`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written/email/electronic shipment instructions to the Service Provider for transportation of container cargo between the agreed terminals. +Prepare all necessary documents and facilities for shipment. +Ensure the minimum supply of containers per shipment agreed with the Service Provider for the selected loading terminal and destination. +One flat wagon must carry either one 40ft container or two 20ft containers. +If two 20ft containers are loaded on one flat wagon, their weight difference must not exceed 10 tons. +Ensure timely loading and unloading of cargo. +Assign representatives at both ends to oversee container handover and ensure the necessary arrangements for cargo reception at the destination upon arrival. +Maintain and provide detailed cargo information (type, weight, destination, etc.). +Be responsible for cargo handling, loading, and unloading of both empty and full containers at Modjo and Dire Dawa dry port. +Book wagons at least five (5) days in advance. +Ensure containers are ready one day before the planned loading date. +Collect the full container from Galaan Multipurpose Port (GMP) within three (3) calendar days from the day following the arrival notice. +If the Client fails to collect the container within the specified period, the Service Provider shall have the right to reposition the container to any location it deems appropriate; in such case, the Service Provider shall not be held responsible for any damage or loss arising from such repositioning. +If the Client fails to collect the container from Galaan Multipurpose Port within the specified period, the Client shall be liable to pay demurrage charges of 15 USD per day per 20ft container and 27 USD per day per 40ft container, calculated based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +In the event the Client fails to collect the container(s) within the specified period, the Client shall be liable to pay double handling charges of 27 USD per 20ft container per handling or 40 USD per 40ft container per handling. +Collect the containers within one day at Dire Dawa and Modjo dry port, or make the necessary payment to the dry port as per the standard of the dry port. +Once empty containers are returned from the Client's premises and stored at a dry port while awaiting train allocation, any demurrage and/or storage charges incurred from the dry port thereafter shall be the sole responsibility of the Client. +Provide clean empty containers that meet the receiving terminal's standards; additional cleaning costs incurred due to non-compliance will be borne by the Client. +Ensure containers are structurally intact and meet weight distribution requirements. +Prohibited cargo: cargo covered with tarpaulin is not allowed due to safety risks. +Notify the Service Provider forty-eight (48) hours in advance before wagon booking if transporting hazardous or valuable goods. +If a booked wagon is not loaded due to Client-related issues, including but not limited to a damaged container, missing lock, unpaid demurrage, port system errors, or incomplete documentation and submission, the Client shall be charged 100% of the total price of the reserved wagon. +Refund requests before wagon booking require an official request, with a 3% administrative fee deducted. If the refund is due to the Service Provider, the Client shall receive a full refund. Refunds not requested and completed within six months shall be considered waived. +Pay 100% of the transportation fee in advance for each train set. +Settle additional penalties due to non-compliance within ten (10) days of invoice issuance. +Late payment incurs a penalty of an additional 10%.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Assign the necessary voyage based on the operational schedule and cargo demand, and notify the train schedule 48 hours in advance. +Provide a list of wagons/voyage or train number 24 hours in advance and update corrections 12 hours before arrival. +Provide safe transportation of the containers. +Deliver the cargo within two (2) days after train departure, provided that all required documents are submitted on time and no unforeseen circumstances or events occur. +In the event of export cargo operations, the Service Provider may prioritize the loading of export containers during the loading of empty containers and the unloading of containers from the train at Galaan Multipurpose Port. +The Service Provider reserves the right to refuse the transportation of any cargo classified as dangerous goods. +If any operational, technical, or mechanical problem occurs throughout the transit, notify the Client and the relevant authorities and arrange cargo transfer within four (4) days. +Provide accident or defect reports if needed. +Buy cargo liability insurance for each wagon.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for delays or non-performance caused by force majeure events beyond their reasonable control. +Force majeure shall be interpreted in accordance with the Ethiopian Civil Code.`, + ), + a( + "pricing", + "Contract Price and Terms of Payment", + `The applicable rate per 40ft container or per two (2) 20ft containers for the agreed route shall be as per the prevailing EDR domestic container tariff, as set out in the commercial schedule of this contract. +If cargo exceeds 40 tons per two 20ft containers of gross weight, additional charges apply proportionally. +Gross weight shall be the total sum of cargo, packing, and container tare weight. +Payment for any additional tonnage shall be made in advance before the container is loaded onto the wagon. +The price of loading and unloading and container handling at Modjo and Dire Dawa dry port is not part of this contract; it is the Client's responsibility. +Additional costs (if applicable): +- Last-mile delivery service by truck from the destination terminal to the Client's premises shall incur an additional cost, fully covered by the Client. +- For clients utilizing EDR's last-mile logistics services, the applicable charges shall vary based on the cargo movement route and shall be communicated to the Client by the Service Provider after receiving the necessary details regarding the cargo destination, type, and weight. +All payments shall be made one hundred percent (100%) in advance. +Payment may be made in Ethiopian Birr based on the Commercial Bank of Ethiopia's daily selling exchange rate on the date the wagon or train number is provided; if the exchange rate changes between the payment and the wagon assignment date, adjustments will be made accordingly.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following documents constitute this contract: +- Amendments (if any). +- This Contract Agreement. +- Final minutes of negotiation (if any). +If there is any discrepancy between the documents, they shall be interpreted with priority in the order listed above.`, + ), + a( + "documentation", + "Documentation Requirements", + `Equipment Interchange Receipt, Railway Waybill, Container Carriage Acceptance Sheet, and incidental charges (if any). +Payment summary as per the agreed contract price (if required).`, + ), + a( + "consignment-notes", + "Consignment Notes", + `The Service Provider must certify the taking over and handing over of the container cargo on the Freight Carriage Acceptance Sheet of EDR in an appropriate manner and provide it to the Client. +The Freight Carriage Acceptance Sheet shall be prima facie evidence of the receipt of the container cargo by the Service Provider and of the kind, number, and weight of the goods. +Upon delivery of the cargo to the Client, the Cargo Handover Out Voucher signed by both parties shall serve as evidence of cargo receipt.`, + ), + a( + "amendment", + "Amendment", + `This contract can be amended by mutual agreement. +Notwithstanding the above, the Service Provider may revise transport tariffs due to operational and regulatory changes by providing at least five (5) working days' prior written notice to the Client.`, + ), + a( + "termination", + "Termination of Contract", + `The contract may be terminated: +- By mutual agreement. +- Upon completion of the contract period or agreed cargo shipments. +- If either party breaches fundamental terms.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `The contract is valid once signed by both parties and witnesses.`, + ), + a( + "duration", + "Contract Period", + `Valid until August 31, {{contractYear}}, with a possible extension upon mutual agreement.`, + ), + a( + "disputes", + "Settlement of Disputes", + `Disputes shall be settled amicably. +If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`, + ), + ], +}; + +export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [ + IMPORT_BULK, + EXPORT_BULK, + INTERCITY_BULK, + IMPORT_CONTAINER, + EXPORT_CONTAINER, + INTERCITY_CONTAINER, +]; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 0bee03fc6..15c8cf19c 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -13,6 +13,7 @@ import { PackageOpen, Paperclip, Receipt, + ScrollText, Send, Settings, ShieldCheck, @@ -84,6 +85,8 @@ import UserManagementPage from "./pages/dashboard/user-management/UserManagement import UsersPage from "./pages/dashboard/user-management/UsersPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; +import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; @@ -461,6 +464,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.admin, }, + { + label: "Contract templates", + href: "/dashboard/contract-templates", + icon: , + permission: FREIGHT_PERMS.admin, + }, ], }, { @@ -1252,6 +1261,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> ["contract-templates", "list"] as const, + byCode: (code: string) => ["contract-templates", "detail", code] as const, + preview: (code: string) => ["contract-templates", "preview", code] as const, +}; + +export function useContractTemplates() { + return useQuery({ + queryKey: KEYS.list(), + queryFn: () => contractTemplatesService.list(), + }); +} + +export function useContractTemplate(code: string | undefined) { + return useQuery({ + queryKey: KEYS.byCode(code ?? ""), + queryFn: () => contractTemplatesService.getByCode(code as string), + enabled: Boolean(code), + }); +} + +/** Rendered mock-data HTML preview of the template's saved state. */ +export function useContractTemplatePreview(code: string | undefined, enabled = true) { + return useQuery({ + queryKey: KEYS.preview(code ?? ""), + queryFn: () => contractTemplatesService.preview(code as string), + enabled: Boolean(code) && enabled, + staleTime: 0, + }); +} + +function useTemplateMutation( + mutationFn: (vars: TVariables) => Promise, + successMessage: string, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: () => { + toast.success(successMessage); + void queryClient.invalidateQueries({ queryKey: KEYS.ROOT }); + }, + onError: (error: unknown) => { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Something went wrong"; + toast.error(Array.isArray(message) ? message.join(", ") : message); + }, + }); +} + +export function useUpdateContractTemplate(code: string) { + return useTemplateMutation( + (payload: UpdateContractTemplatePayload) => + contractTemplatesService.update(code, payload), + "Template updated", + ); +} + +export function useAddArticle(code: string) { + return useTemplateMutation( + (payload: ArticlePayload) => contractTemplatesService.addArticle(code, payload), + "Article added", + ); +} + +export function useUpdateArticle(code: string) { + return useTemplateMutation( + (vars: { articleId: string; payload: Partial }) => + contractTemplatesService.updateArticle(code, vars.articleId, vars.payload), + "Article updated", + ); +} + +export function useRemoveArticle(code: string) { + return useTemplateMutation( + (articleId: string) => contractTemplatesService.removeArticle(code, articleId), + "Article removed", + ); +} + +export function useReplaceArticles(code: string) { + return useTemplateMutation( + (articles: Array<{ id?: string; title: string; body: string }>) => + contractTemplatesService.replaceArticles(code, articles), + "Articles reordered", + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx new file mode 100644 index 000000000..0ad7ef301 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplateEditorPage.tsx @@ -0,0 +1,468 @@ +import { useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + ActionIcon, + Badge, + Button, + Card, + Center, + Group, + Loader, + Modal, + Paper, + Stack, + Switch, + Text, + Textarea, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { + ArrowDown, + ArrowUp, + Pencil, + Plus, + RefreshCw, + Settings2, + Trash2, +} from "lucide-react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { + useAddArticle, + useContractTemplate, + useContractTemplatePreview, + useRemoveArticle, + useReplaceArticles, + useUpdateArticle, + useUpdateContractTemplate, +} from "@/hooks/contract-templates/useContractTemplates"; +import type { ContractTemplateArticle } from "@/services/contract-templates.service"; + +const BODY_HINT = + 'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.'; + +interface ArticleDraft { + id?: string; + title: string; + body: string; +} + +export default function ContractTemplateEditorPage() { + const { code } = useParams<{ code: string }>(); + const { data: template, isLoading } = useContractTemplate(code); + const preview = useContractTemplatePreview(code); + + const updateTemplate = useUpdateContractTemplate(code ?? ""); + const addArticle = useAddArticle(code ?? ""); + const updateArticle = useUpdateArticle(code ?? ""); + const removeArticle = useRemoveArticle(code ?? ""); + const replaceArticles = useReplaceArticles(code ?? ""); + + const [articleDraft, setArticleDraft] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + + const sortedArticles = useMemo( + () => [...(template?.articles ?? [])].sort((a, b) => a.order - b.order), + [template], + ); + + const moveArticle = (index: number, delta: -1 | 1) => { + const next = [...sortedArticles]; + const target = index + delta; + if (target < 0 || target >= next.length) return; + [next[index], next[target]] = [next[target], next[index]]; + replaceArticles.mutate( + next.map(({ id, title, body }) => ({ id, title, body })), + ); + }; + + const saveArticle = () => { + if (!articleDraft) return; + if (articleDraft.id) { + updateArticle.mutate({ + articleId: articleDraft.id, + payload: { title: articleDraft.title, body: articleDraft.body }, + }); + } else { + addArticle.mutate({ title: articleDraft.title, body: articleDraft.body }); + } + setArticleDraft(null); + }; + + if (isLoading || !template) { + return ( + +
+ +
+
+ ); + } + + return ( + + + + {template.code.replaceAll("_", " · ")} + + {!template.isActive && ( + + Inactive + + )} +
+ } + action={ + + + updateTemplate.mutate({ isActive: event.currentTarget.checked }) + } + /> + + + + } + /> + +
+ {/* ── Article list ─────────────────────────────────────────────── */} + + {sortedArticles.map((article, index) => ( + + +
+ + Article {index + 1} + + {article.title} + + {article.body} + +
+ + + moveArticle(index, -1)} + > + + + + + moveArticle(index, 1)} + > + + + + + + setArticleDraft({ + id: article.id, + title: article.title, + body: article.body, + }) + } + > + + + + + setDeleteTarget(article)} + > + + + + +
+
+ ))} + {sortedArticles.length === 0 && ( + +
+ + No articles yet — add the first article to build this contract. + +
+
+ )} +
+ + {/* ── Live preview ─────────────────────────────────────────────── */} + + + + Document preview (mock data) + + + void preview.refetch()} + > + + + + + + {preview.isLoading ? ( +
+ +
+ ) : ( +