diff --git a/.gitignore b/.gitignore index ca2a5b7af..21edddcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,11 @@ coverage/ \#*\# .\#* docker-compose.override.yml + +# cypress e2e artifacts +e2e/**/cypress/videos/ +e2e/**/cypress/screenshots/ +e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 9769a7f18..d478da8f3 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) => ), ); +/** + * Read-only reference data (yard dropdowns, search filters): any signed-in + * staff. Menu/page visibility stays permission-gated in the frontend — this + * only lets forms populate their lookups. + */ +export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); + export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const TrainSchedulingView = () => diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts new file mode 100644 index 000000000..6aaa24a26 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { assertExportReceivedWithGrn } from './export-received-gate'; + +const db = (rows: unknown[]) => + ({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource; + +describe('assertExportReceivedWithGrn', () => { + it('passes when the export booking has a received row with a GRN', async () => { + await expect( + assertExportReceivedWithGrn(db([{ '?column?': 1 }]), { + id: 'b-1', + tradeDirection: 'EXPORT', + }), + ).resolves.toBeUndefined(); + }); + + it('rejects an export booking with nothing received', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('never blocks import — it loads off a train, not out of the warehouse', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }), + ).resolves.toBeUndefined(); + // Import short-circuits before querying. + expect((source.query as jest.Mock)).not.toHaveBeenCalled(); + }); + + it('does not block intercity cargo', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts new file mode 100644 index 000000000..0e1728800 --- /dev/null +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource, EntityManager } from 'typeorm'; + +/** The booking fields the gate needs. */ +export interface ExportLoadGateBooking { + id: string; + tradeDirection?: string | null; +} + +/** + * Export cargo may not be loaded onto its train until it has physically reached + * the warehouse and been issued a GRN — whether it got there by first-mile or by + * the customer's own truck, and even though a wagon is already allocated. An + * allocation is a plan; the GRN is the proof the goods are actually in hand. + * + * Several loading paths (per-yard load, workspace confirm-loaded) marked cargo + * loaded straight off the allocation, skipping the warehouse, so a booking could + * ride the train with nothing ever received. This closes that for export; import + * loads off a train and is unaffected. + * + * "Received with a GRN" = an inventory row that has reached the warehouse + * (RECEIVED or any later stage) and carries a GRN, in the column or the notes + * fallback older rows use. + */ +export async function assertExportReceivedWithGrn( + db: DataSource | EntityManager, + booking: ExportLoadGateBooking, +): Promise { + if (booking.tradeDirection !== 'EXPORT') return; + + const [row] = await db.query( + `SELECT 1 + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + LIMIT 1`, + [booking.id], + ); + + if (!row) { + throw new BadRequestException( + 'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.', + ); + } +} diff --git a/apps/edr-freight-api/src/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts new file mode 100644 index 000000000..2f5288048 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; + +type MileRecord = { + bookingId?: string | null; + advancedPayment?: number | string | null; + booking?: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + units?: Array<{ vgmTons?: number | string | null }> | null; + }> | null; + } | null; +}; + +/** + * Display enrichment for first/last-mile lists (Assign Vehicle modal etc.): + * - Advance payment: mile records are created with advanced_payment 0 — the + * real advance is the FIRST_MILE/LAST_MILE line the customer already paid + * on the booking invoice. + * - Cargo tons: container bookings often carry tonnage on the per-unit VGMs + * while cargo_total_weight_vgm stays 0 — fall back to the summed units. + * Fills both in-memory on the loaded records; nothing is persisted. + */ +export async function attachMileFinancials( + dataSource: DataSource, + records: MileRecord[], + chargeType: 'FIRST_MILE' | 'LAST_MILE', +): Promise { + for (const r of records) { + const b = r.booking; + if (!b || Number(b.cargoTotalWeightVgm) > 0) continue; + const unitTons = (b.bookingContainers ?? []).reduce( + (sum, bc) => + sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0), + 0, + ); + if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); + } + + const needAdvance = records.filter( + (r) => r.bookingId && !(Number(r.advancedPayment) > 0), + ); + if (!needAdvance.length) return; + + const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query( + `SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount + FROM freight.invoice_lines il + JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL + WHERE i.source = 'booking' + AND i.status = 'PAID' + AND i.source_id = ANY($1::text[]) + AND il.charge_type = $2 + AND il.deleted_at IS NULL + GROUP BY i.source_id`, + [needAdvance.map((r) => r.bookingId), chargeType], + ); + const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)])); + for (const r of needAdvance) { + const paid = byBooking.get(r.bookingId as string); + if (paid) r.advancedPayment = paid; + } +} diff --git a/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts new file mode 100644 index 000000000..42352237d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2060000000000-CreateYardDistances.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Configured rail distance between two yards (Configuration → Yard Distances). + * Route creation resolves each segment's km from here (symmetric lookup: + * one A↔B row serves both directions) instead of accepting free-text km, + * and snapshots the value onto route_milestones.distance_km. + * + * Uniqueness is a partial index (deleted_at IS NULL) so a soft-deleted pair + * can be re-created. + */ +export class CreateYardDistances2060000000000 implements MigrationInterface { + name = 'CreateYardDistances2060000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.yard_distances ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL REFERENCES freight.yards(id), + to_yard_id uuid NOT NULL REFERENCES freight.yards(id), + distance_km numeric(10,2) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_from_yard + ON freight.yard_distances (from_yard_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_yard_distances_to_yard + ON freight.yard_distances (to_yard_id); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_yard_distances_pair + ON freight.yard_distances (from_yard_id, to_yard_id) + WHERE deleted_at IS NULL; + `); + // Backfill from segments already stored on existing routes so editing them + // does not immediately fail the "pair not configured" check. One row per + // unordered pair; where routes disagree the longest segment wins. + await queryRunner.query(` + INSERT INTO freight.yard_distances (from_yard_id, to_yard_id, distance_km) + SELECT DISTINCT ON (LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id)) + prev_yard_id, yard_id, distance_km + FROM ( + SELECT + yard_id, + distance_km, + LAG(yard_id) OVER (PARTITION BY route_id ORDER BY sequence_no) AS prev_yard_id + FROM freight.route_milestones + WHERE deleted_at IS NULL + ) segments + WHERE prev_yard_id IS NOT NULL + AND distance_km IS NOT NULL + AND distance_km > 0 + ORDER BY LEAST(prev_yard_id, yard_id), GREATEST(prev_yard_id, yard_id), distance_km DESC + ON CONFLICT DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.yard_distances;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts new file mode 100644 index 000000000..c8f48af05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2440000000000-AddHandoverEdrAssignment.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck EDR last-mile handovers. `truck_assignment_id` FKs + * customer_truck_assignments (self-haul only), so EDR trucks need their own + * link to the last-mile vehicle assignment that hauled the goods. Generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal — one per truck, or booking-level (both ids null) + * when the truck cannot be resolved. + */ +export class AddHandoverEdrAssignment2440000000000 implements MigrationInterface { + name = 'AddHandoverEdrAssignment2440000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS edr_assignment_id uuid + REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE SET NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_edr_truck" + ON freight.booking_handovers (booking_id, edr_assignment_id) + WHERE deleted_at IS NULL AND edr_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_booking_handovers_booking_edr_truck";`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS edr_assignment_id;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 50c90213b..006b482f4 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { DataSource } from 'typeorm'; import { collectPermissionKeys, @@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; @Injectable() export class FreightMeService { - getEnrichedProfile(user: TCurrentUser) { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * The JWT session snapshot has no position TYPE, but the backoffice needs it + * (GL sub-positions are identified by type key). Resolved live from IAM. + */ + private async lookupPositionType( + positionId: string | undefined, + ): Promise<{ key: string; name: unknown } | null> { + if (!positionId) return null; + try { + const rows: { key: string; name: unknown }[] = await this.dataSource.query( + `SELECT pt.key, pt.name + FROM iam.positions p + JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE p.id = $1`, + [positionId], + ); + return rows[0] ?? null; + } catch { + return null; // iam schema unreachable — degrade to the old payload shape + } + } + + async getEnrichedProfile(user: TCurrentUser) { + const positionType = await this.lookupPositionType( + user.employee?.position?.id, + ); + const employee = user.employee ? [ { @@ -27,6 +57,7 @@ export class FreightMeService { isDelegate: user.employee.position.isDelegate, parentPositionId: user.employee.position.parentPositionId, permissions: user.employee.position.permissions ?? [], + positionType, }, ] : [], diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index 1bdde71be..caa41f5e9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -168,6 +168,17 @@ export class BookingLifecycleNotifierService { }); } + /** Intercity documents approved → booking waits in the ride-along pool. */ + intercityDocumentsApproved(b: Booking): void { + const msg = + `Documents for intercity booking ${b.reference} are approved. ` + + `Operations will assign your shipment to a passing train; payment opens once it is accepted.`; + void this.notifyContact(b, msg, 'DOCUMENTS APPROVED'); + this.inApp(b, 'Documents approved', msg, { + type: NotificationType.CLEARANCE_DECISION, + }); + } + /** Operations returned the operation request for changes. */ operationChangesRequested(b: Booking, note: string): void { const msg = diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 3f4f64186..fecd9111a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -163,11 +163,12 @@ describe('BookingPricingService — domestic corridor', () => { computeBaseRailLinesWithRates: ( b: Booking, input: { containers: [] }, - ) => Promise<{ lineItems: Array<{ amount: number }> }>; + ) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>; } ).computeBaseRailLinesWithRates(booking, { containers: [] }); expect(result.lineItems).toHaveLength(0); + expect(result.blocked).toHaveLength(1); }); it('does not price containers off a rate configured for a different leg', async () => { @@ -197,4 +198,45 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.lineItems).toHaveLength(0); }); + + // A mixed booking where only one container size has a configured rate must + // hard-block, not silently carry the unconfigured size for free. + it('blocks the unconfigured container size and prices the configured one', async () => { + const fortyOnly: Rate = { + ...intercityContainerUsd, + id: 'rate-ct-40-only', + containerTypeId: 'ct-40', + } as Rate; + ratesService.findLiveRates.mockResolvedValue([fortyOnly]); + + const booking = { + id: 'b-5', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [ + { containerTypeId: 'ct-40', quantity: 2 }, + { containerTypeId: 'ct-20', quantity: 3 }, + ], + }); + + expect(result.lineItems).toHaveLength(1); + expect(result.blocked).toHaveLength(1); + expect(result.blocked[0]).toContain('rate is configured'); + }); }); 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 cbb630794..32aa1a880 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 @@ -137,8 +137,12 @@ export class BookingPricingService { const lineItems: PriceLineItemDto[] = []; let total = 0; - const { lineItems: baseLines, usedRates: baseRates } = - await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); + const { + lineItems: baseLines, + usedRates: baseRates, + warnings: baseWarnings, + blocked: baseBlocked, + } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); total += line.amount; @@ -247,8 +251,8 @@ export class BookingPricingService { usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, - warnings: ruleResult.warnings, - hardBlocked: ruleResult.hardBlocked, + warnings: [...ruleResult.warnings, ...baseWarnings], + hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked], overweightLines, }; } @@ -454,7 +458,12 @@ export class BookingPricingService { booking: Booking, evalInput: BookingEvaluationInput, frozenRates: Map | null = null, - ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { + ): Promise<{ + lineItems: PriceLineItemDto[]; + usedRates: Rate[]; + warnings: string[]; + blocked: string[]; + }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -476,6 +485,8 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); + const warnings: string[] = []; + const blocked: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { @@ -487,47 +498,66 @@ export class BookingPricingService { booking.originYardId, booking.destinationYardId, ); - if (!rate) continue; - - usedRatesMap.set(rate.id, rate); - const unitUsd = Number(rate.rateValue); // H15: frozen contract rate for this container size, when present — its // unitPrice is already in the booking currency (no USD→currency convert). + // It also stands on its own: a contract line prices off the agreed rate + // even when nobody configured a live rate for this leg + type yet. const frozen = await this.frozenRateForContainer( frozenRates, container.containerTypeId, paymentCurrency, ); + const label = await this.containerTypeLabel(container.containerTypeId); + if (!rate && !frozen) { + // Never price this line off another container type's (or another + // route's) rate, and never let an unpriced line through: a booking + // that ships a container type nobody configured a rate for would be + // carried for free. Hard-block instead — the customer drops the line + // or EDR configures the rate. + blocked.push( + `No ${rateType} rate is configured for ${label} on this route — ` + + `the booking cannot be priced. Remove the ${label} line or ask EDR ` + + 'to configure its rate for this origin → destination.', + ); + continue; + } + + const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER'; let amount: number; let unitAmount: number; if (frozen) { unitAmount = Number(frozen.unitPrice); amount = this.amountForUnit( - rate.rateUnit, + rateUnit, unitAmount, container.quantity, wagonCount, ); } else { - const usdAmount = this.amountForRate(rate, container.quantity, wagonCount); + const unitUsd = Number(rate!.rateValue); + const usdAmount = this.amountForRate(rate!, container.quantity, wagonCount); amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd; } - const label = await this.containerTypeLabel(container.containerTypeId); + if (rate) usedRatesMap.set(rate.id, rate); lines.push({ code: rateType, description: `${label} rail freight`, amount, unitAmount, - unit: rate.rateUnit, - quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount), + unit: rateUnit, + quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, wagonCount), currency: paymentCurrency, }); } - if (lines.length === 0) { + if (lines.length === 0 && evalInput.containers.length === 0) { // Bulk (and any booking with no container lines) still has to price off a // rate configured for this leg — never one belonging to another route. + // Container bookings never reach this fallback: their lines price per + // container type above or stay unpriced with a warning — falling back to + // a corridor rate of a DIFFERENT container type billed once (qty 1) is + // how a 38-container booking was invoiced 40 USD instead of 1900. const fallback = liveRates.find( (r) => r.rateType === rateType && @@ -570,10 +600,18 @@ export class BookingPricingService { quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); + } else if (isBulk) { + // Same rule as container lines: bulk freight with no rate on this leg + // must not proceed unpriced. + blocked.push( + `No ${rateType} rate is configured for this route — the booking ` + + 'cannot be priced. Ask EDR to configure the rate for this ' + + 'origin → destination.', + ); } } - return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; + return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked }; } /** diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 29fec7997..36705daa0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -7,6 +7,7 @@ import { Logger, Optional, } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -349,6 +350,22 @@ export class BookingTransitionService { return fresh; } + /** + * Import EDR last-mile: every handover signed + every truck departed ⇒ the + * warehouses module delivered the goods and asks the booking to complete. + * Best-effort — a booking already COMPLETED (or not yet in transit) just logs. + */ + @OnEvent('import.handover.completed') + async onImportHandoverCompleted(payload: { bookingId: string }): Promise { + try { + await this.complete(payload.bookingId); + } catch (err) { + this.logger.log( + `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`, + ); + } + } + async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); @@ -840,6 +857,22 @@ export class BookingTransitionService { } } + // Intercity: there is no shipment-day request step — an approved booking + // goes straight to FULLY_EXECUTED, which is what the intercity ride-along + // pool keys on. Staff then accept it onto a passing train (that accept + // opens the pay window). + if (booking.tradeDirection === "DOMESTIC") { + const now = new Date(); + await this.bookingsRepository.update(bookingId, { + status: "FULLY_EXECUTED", + fullyExecutedAt: now, + lockedAt: booking.lockedAt ?? now, + } as never); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.intercityDocumentsApproved(fresh); + return fresh; + } + await this.bookingsRepository.update(bookingId, { status: "CLEARANCE_READY", } as never); 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 7f8718480..e95cd65c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1213,9 +1213,9 @@ 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. + * has a generated-but-unsigned handover (self-haul or EDR last-mile), 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); @@ -1224,8 +1224,7 @@ export class BookingsService { `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'`, + AND signed_at IS NULL AND deleted_at IS NULL`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); @@ -1559,14 +1558,12 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned SELF_HAUL handover means the customer must approve - // delivery from the portal (booking-based, one per booking). EDR last-mile - // handovers are per delivering truck and signed by the receiver at the door, - // so they never surface the portal "Approve delivery" action. + // A generated-but-unsigned handover means the customer must approve delivery + // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: + // per delivering truck (generated on truck exit), signed one by one. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index 969de5583..0e858e702 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -1,7 +1,10 @@ import { clearanceSettingCode, clearanceOutputSettingCode, + clearanceCodesForBooking, + INTERCITY_DOCUMENTS_SETTING_CODE, } from './clearance.util'; +import type { Booking } from './entities/booking.entity'; describe('clearance.util — clearanceSettingCode', () => { it('resolves import container with/without customs', () => { @@ -24,9 +27,49 @@ describe('clearance.util — clearanceSettingCode', () => { ); }); - it('returns null for DOMESTIC (no clearance gate)', () => { - expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull(); - expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull(); + it('resolves the intercity document set for DOMESTIC regardless of customs/freight', () => { + expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBe( + INTERCITY_DOCUMENTS_SETTING_CODE, + ); + }); +}); + +describe('clearance.util — clearanceCodesForBooking (intercity)', () => { + const base = { + tradeDirection: 'DOMESTIC', + freightType: 'CONTAINER', + serviceType: null, + customsClearingEnabled: false, + }; + + it('GENERAL drawdowns and direct bookings carry the per-booking intercity set', () => { + const general = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'GENERAL', + } as unknown as Booking); + expect(general.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + expect(general.outputCode).toBeNull(); + + const direct = clearanceCodesForBooking({ + ...base, + contractId: null, + contractKind: null, + } as unknown as Booking); + expect(direct.inputCode).toBe(INTERCITY_DOCUMENTS_SETTING_CODE); + }); + + it('ONE_TIME contract drawdowns skip the per-booking set (contract collected it)', () => { + const drawdown = clearanceCodesForBooking({ + ...base, + contractId: 'c1', + contractKind: 'ONE_TIME', + } as unknown as Booking); + expect(drawdown.inputCode).toBeNull(); + expect(drawdown.outputCode).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 1cc6503df..5a63beca6 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -9,11 +9,19 @@ import { Booking } from './entities/booking.entity'; type Op = 'import' | 'export'; type Freight = 'container' | 'bulk'; +/** + * The single (admin-configured) document set intercity shipments upload. + * DOMESTIC has no customs, so one shared set serves contracts and bookings: + * ONE_TIME collects it at contract level, GENERAL per booking — Operations + * reviews either way. + */ +export const INTERCITY_DOCUMENTS_SETTING_CODE = 'intercity_documents'; + /** Trade direction → clearance operation. DOMESTIC has no customs clearance. */ function operationFor(tradeDirection: string): Op | null { if (tradeDirection === 'IMPORT') return 'import'; if (tradeDirection === 'EXPORT') return 'export'; - return null; // DOMESTIC / intercity — no clearance gate + return null; // DOMESTIC / intercity — no customs operation } function freightFor(freightType: string): Freight { @@ -26,6 +34,9 @@ export function clearanceSettingCode( freightType: string, includesCustoms: boolean, ): string | null { + // Intercity: no customs, but the admin-configured intercity document set is + // still collected and ops-reviewed before the shipment may board a train. + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); @@ -66,6 +77,16 @@ export function clearanceCodesForBooking(booking: Booking): { const includesCustoms = Boolean(booking.serviceType?.includesCustoms) || Boolean(booking.customsClearingEnabled); + // Intercity drawdowns under a ONE_TIME contract already cleared the intercity + // document set on the CONTRACT (post-signature); only GENERAL drawdowns and + // direct (contract-less) bookings carry the per-booking set. + if ( + booking.tradeDirection === 'DOMESTIC' && + booking.contractId && + booking.contractKind === 'ONE_TIME' + ) { + return { inputCode: null, outputCode: null, includesCustoms: false }; + } return { inputCode: clearanceSettingCode( booking.tradeDirection, 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 e230380f0..a4360447d 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 @@ -198,11 +198,12 @@ export class ContractBookingService { // GENERAL without customs (Path A) ALSO clears per booking: the customer // uploads his own clearance proof on each booking and Operations reviews it // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → - // requestOperation machine). DOMESTIC has no border, so no gate. + // requestOperation machine). GENERAL intercity (DOMESTIC) follows the same + // per-booking gate with the intercity document set — ops finalize then puts + // the booking straight into the ride-along pool (FULLY_EXECUTED), since + // intercity has no shipment-day request step. const generalSelfClear = - contract.contractKind === 'GENERAL' && - !contract.customsClearingEnabled && - contract.tradeDirection !== 'DOMESTIC'; + contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled; // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at @@ -300,48 +301,63 @@ export class ContractBookingService { } as never), ); - // Persist container lines + per-unit container numbers (container freight only). - if (freightType === 'CONTAINER') { - await this.persistContainers(booking.id, contract, dto); - } - - // Reload with containers to compute the total from contract unit rates × qty. - const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); - if (loaded) { + // Everything between the insert and the priced update must be all-or-nothing: + // a throw part-way (container persist, weight rules, pricing) would otherwise + // leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that + // occupies the one-time contract's single active-booking slot until the + // doc-review sweep expires it — and the clearance cycle still points at the + // previous booking, so the hub keeps offering "Rebook" against a dead draft. + try { + // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { - await this.applyWeightResults(loaded); + await this.persistContainers(booking.id, contract, dto); } - const computed = await this.bookingPricingService.computePriceForBooking(loaded); - // Reject a zero-price booking outright. A total of 0 means no contract rate - // matched the route/container (or the rate is unset), so the booking is not - // valid to ship or invoice. Roll back the just-inserted row + its lines so it - // does NOT occupy the one-time contract's single active-booking slot — else - // the customer's retry hits "already has an active booking" against a broken - // draft. The customer must fix the contract's rates, then rebook. - if (!(computed.totalAmount > 0)) { - await this.bookingsRepository.deleteContainers(booking.id); - await this.bookingsRepository.hardDelete(booking.id); - throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', - ); - } - await this.bookingsRepository.update(booking.id, { - totalAmount: computed.totalAmount, - priorityScore: computed.priorityScore, - pricingBreakdown: { - lineItems: computed.lineItems, + + // Reload with containers to compute the total from contract unit rates × qty. + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has + // a positive total, so the zero-price gate below misses it — enforce + // the pricing hard blocks first. The catch below rolls everything back. + if (computed.hardBlocked.length > 0) { + throw new BadRequestException(computed.hardBlocked.join('; ')); + } + // Reject a zero-price booking outright. A total of 0 means no contract rate + // matched the route/container (or the rate is unset), so the booking is not + // valid to ship or invoice. The catch below rolls back the row + its lines. + if (!(computed.totalAmount > 0)) { + throw new BadRequestException( + 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', + ); + } + await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, - currency: computed.currency, - generatedAt: new Date().toISOString(), - }, - } as never); - await this.bookingPricingService.createPricingSnapshots( - booking.id, - computed.usedRates, - computed.appliedModifiers, - ); - warnings.push(...computed.warnings); + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + booking.id, + computed.usedRates, + computed.appliedModifiers, + ); + warnings.push(...computed.warnings); + } + } catch (err) { + await this.bookingsRepository + .deleteContainers(booking.id) + .catch(() => undefined); + await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined); + throw err; } // Wagon consolidation gate. A container drawdown whose lines leave a partial @@ -734,15 +750,20 @@ export class ContractBookingService { const computed = await this.bookingPricingService.computePriceForBooking(loaded); // A zero price means no contract rate matches — roll the cargo back so // the instance stays CLEARANCE_READY and can be completed again once - // the contract rates are fixed (the clearance work is not lost). - if (!(computed.totalAmount > 0)) { + // the contract rates are fixed (the clearance work is not lost). A + // pricing hard block (e.g. one of two container sizes has no rate) + // rolls back the same way: a partially-priced total is positive but + // the booking must not proceed. + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { await this.bookingsRepository.deleteContainers(booking.id); await this.bookingsRepository.update(booking.id, { cargoTotalWeightVgm: 0, } as never); throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join('; ') + : 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', ); } await this.bookingsRepository.update(booking.id, { @@ -1599,17 +1620,23 @@ export class ContractBookingService { throw new BadRequestException('At least one container line is required.'); } - const allowedSizes = new Set( + // Size strings arrive in mixed formats ("20ft" from the contract scope, + // bare "20" from the rebook seed) — compare numerically so format never + // fails a size that IS in scope. + const allowedSizesFt = new Set( (contract.cargoScope ?? []) - .map((c) => c.containerSize) - .filter((s): s is string => !!s), + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), ); const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); for (const line of lines) { - if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { + if ( + allowedSizesFt.size && + !allowedSizesFt.has(parseInt(line.containerSize, 10)) + ) { throw new BadRequestException( `Container size ${line.containerSize} is outside the contract scope.`, ); @@ -1746,11 +1773,33 @@ export class ContractBookingService { }), ); + // Same size-scope gate persistContainers enforces at create, surfaced as a + // blocking preview error so the form can't confirm a size the contract does + // not cover. Numeric compare — "20" and "20ft" are the same size. + const allowedSizesFt = new Set( + (contract.cargoScope ?? []) + .map((c) => parseInt(c.containerSize ?? '', 10)) + .filter((n) => Number.isFinite(n)), + ); + const scopeErrors = allowedSizesFt.size + ? [ + ...new Set( + lines + .map((l) => l.containerSize) + .filter((s) => !allowedSizesFt.has(parseInt(s, 10))), + ), + ].map((s) => `Container size ${s} is outside the contract scope.`) + : []; + // The unsaved twin of the booking createUnderContract would write: same // denormalized contract fields, same container-line math. No id → the // pricing service derives wagon counts from the in-memory lines. const route = await this.resolveRoute(contract, dto.contractRouteId); const previewBooking = Object.assign(new Booking(), { + // contractId makes the preview price off the contract's frozen rate + // snapshots exactly like the persisted booking will — without it the + // preview total is 0 on a leg with no live rate and the form blocks. + contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, paymentCurrency: contract.paymentCurrency, @@ -1858,7 +1907,10 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors, + // Pricing hard blocks (missing rate for a container size / requested + // service) ride the capacity-errors channel so the form hard-blocks in + // the preview instead of failing at the create call. + capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked], containerClashErrors, spaceErrors, lineItems: computed.lineItems, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts index bc1b4ad49..2d26f51dc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -1,4 +1,5 @@ import { Contract } from './entities/contract.entity'; +import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util'; /** * Resolves which seeded clearance FileUploadSetting applies to a contract during @@ -29,13 +30,17 @@ function freightFor(freightType: string): Freight { * own (smaller) clearance proof set → `contract_clearance_selfclear_{op}_{freight}`, * reviewed by Operations rather than GL. * - * DOMESTIC/intercity has no border, so no clearance gate applies on either path. + * DOMESTIC/intercity has no border, but a ONE_TIME intercity contract still + * collects the admin-configured intercity document set after both signatures + * (ops-reviewed, like Path A). GENERAL intercity contracts skip the contract + * gate and collect the same set per booking instead. */ export function contractClearanceSettingCode( tradeDirection: string, freightType: string, includesCustoms: boolean, ): string | null { + if (tradeDirection === 'DOMESTIC') return INTERCITY_DOCUMENTS_SETTING_CODE; const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index e35bd2bf5..ac4fe2a0f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -143,6 +143,19 @@ export class ContractNotifierService { this.inApp(c, 'Contract rejected', msg); } + /** + * A later approver sent the contract back to an earlier stage of the chain. + * Staff-only: the customer is not involved in an internal send-back — their + * contract simply stays "under approval". + */ + sentBackToStep(c: Contract, targetRole: string, reason: string): void { + this.inAppStaff( + c, + 'Contract returned in approval chain', + `Contract ${c.reference} was sent back to the ${targetRole} step. Reason: ${reason}`, + ); + } + /** Staff requested changes before approval. */ changesRequested(c: Contract, note: string): void { const msg = 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 ba825dfaa..ba4fe442f 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 @@ -41,6 +41,7 @@ import { ContractDocumentSnapshotInput, } from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; import { SignContractDto } from './dto/sign-contract.dto'; /** The editable contract-document draft returned for the accept/edit dialog. */ @@ -576,17 +577,24 @@ export class ContractTransitionService { /** * Reject one approval step (line staff / director / CEO). The rejecting - * approver must supply a reason. A rejection is terminal: the whole contract - * moves to REJECTED and the customer must create a new one — there is no - * resubmit of the same contract. The reason is recorded both on the step and - * as a REJECTION review note so it is visible to the customer and the rest of - * the approval chain. + * approver must supply a reason, and picks where the rejection lands: + * + * - **To the customer** (`returnToStepId` omitted — the only option for the + * first approver): terminal. The whole contract moves to REJECTED with a + * REJECTION review note visible to the customer, who must resubmit. + * - **To an earlier approver** (`returnToStepId` = an already-APPROVED + * earlier step): internal send-back. That step and everything after it + * reset to PENDING and the chain re-runs from there; the contract stays + * PENDING_APPROVAL and the customer never sees it. E.g. the director can + * return a contract to line staff, who fix it and approve again, after + * which every later stage re-approves in order. */ async rejectStep( contractId: string, stepId: string, actorId: string, reason: string, + returnToStepId?: string, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); @@ -594,6 +602,20 @@ export class ContractTransitionService { const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step) throw new BadRequestException('Approval step not found'); + // Only the approver whose turn it is may reject — same ordering rule as + // approveStep. Without this, an already-actioned or future step could be + // "rejected" and wipe chain state it never owned. + const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); + if (!next || next.id !== step.id) { + throw new BadRequestException( + 'Only the current pending approval step can be rejected', + ); + } + + if (returnToStepId) { + return this.sendBackToStep(contract, step, actorId, reason, returnToStepId); + } + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'REJECTED', reason); await this.contractsRepository.createReviewNote( @@ -616,6 +638,67 @@ export class ContractTransitionService { return updated; } + /** + * Internal send-back branch of rejectStep: return the contract to an earlier, + * already-approved stage of the chain instead of rejecting it outright. + * Deliberately NOT the terminal path: no clearance-fee expiry (the contract + * is still alive) and no customer-facing REJECTION note — the trail is a + * staff note plus a backoffice inbox ping. + */ + private async sendBackToStep( + contract: Contract, + rejectingStep: ContractApprovalStep, + actorId: string, + reason: string, + returnToStepId: string, + ): Promise { + const target = await this.contractsRepository.findApprovalStepById( + contract.id, + returnToStepId, + ); + if (!target) throw new BadRequestException('Return-to approval step not found'); + if (target.stepOrder >= rejectingStep.stepOrder) { + throw new BadRequestException( + 'A rejection can only be returned to an EARLIER step in the chain — to reject to the customer, omit returnToStepId', + ); + } + if (target.status !== 'APPROVED') { + throw new BadRequestException( + `Return-to step ${target.requiredRole} has not approved yet (status ${target.status})`, + ); + } + + // Staff-visible trail. Written before the reset so the reason survives the + // wipe of per-step notes. + await this.contractsRepository.createReviewNote( + contract.id, + `Returned to ${target.requiredRole} (step ${target.stepOrder}) by ${rejectingStep.requiredRole}: ${reason}`, + 'STAFF_NOTE', + actorId, + 'STAFF', + ); + + // Chain re-runs from the target stage: it and every later step (including + // the rejecting one) go back to PENDING. Legacy approved-by columns are + // left stale on purpose — approval steps are the source of truth and the + // columns get re-stamped on re-approval. + await this.contractsRepository.resetApprovalStepsFrom( + contract.id, + target.stepOrder, + ); + + // A send-back can only happen mid-chain, so the contract must remain (or + // return to) PENDING_APPROVAL — relevant when rejecting from + // APPROVED_PENDING_SIGNATURE. + await this.contractsRepository.update(contract.id, { + status: 'PENDING_APPROVAL', + } as never); + + const updated = await this.contractsService.findById(contract.id); + this.notifier.sentBackToStep(updated, target.requiredRole, reason); + return updated; + } + /** Approve one approval step in sequence; → APPROVED when all complete. */ async approveStep( contractId: string, @@ -1035,8 +1118,8 @@ export class ContractTransitionService { }; // A clearance gate applies whenever a clearance doc set resolves — Path B - // (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC - // resolves to null on both paths and skips straight to executed. + // (customs), Path A self-clearance (IMPORT/EXPORT without customs), or the + // intercity document set (DOMESTIC, ops-reviewed like Path A). const clearanceCode = contractClearanceSettingCode( contract.tradeDirection, contract.freightType, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 249568046..6ca4473cd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -442,7 +442,10 @@ export class ContractsController { FREIGHT_PERMS.contracts.approveDirector, FREIGHT_PERMS.contracts.approveCeo, ]) - @ApiOperation({ summary: 'Reject one approval step (terminal → REJECTED)' }) + @ApiOperation({ + summary: + 'Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)', + }) rejectStep( @Param('id', ParseUUIDPipe) id: string, @Param('stepId', ParseUUIDPipe) stepId: string, @@ -454,6 +457,7 @@ export class ContractsController { stepId, resolveAuthUserId(user), dto.reason, + dto.returnToStepId, ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 533b51b3b..67a3bd101 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -156,6 +156,7 @@ export class ContractsRepository extends BaseRepository { // direct download. Loaded separately to keep pagination counts correct. await this.attachContractFiles(items); await this.attachClearancePhases(items); + await this.attachRejectionNotes(items); const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; return { @@ -228,6 +229,31 @@ export class ContractsRepository extends BaseRepository { } } + /** + * Attach the latest REJECTION review-note body to each REJECTED contract so + * list consumers (portal rows, backoffice queues) can show why without a + * per-contract detail fetch. One query per page, like `attachContractFiles`. + */ + private async attachRejectionNotes(contracts: Contract[]): Promise { + const rejected = contracts.filter((c) => c.status === 'REJECTED'); + if (rejected.length === 0) return; + const ids = rejected.map((c) => c.id); + const rows: Array<{ contract_id: string; body: string }> = + await this.dataSource.query( + `SELECT DISTINCT ON (contract_id) contract_id, body + FROM freight.contract_review_notes + WHERE contract_id = ANY($1) + AND note_type = 'REJECTION' + AND deleted_at IS NULL + ORDER BY contract_id, created_at DESC`, + [ids], + ); + const byContract = new Map(rows.map((r) => [r.contract_id, r.body])); + for (const contract of rejected) { + contract.latestRejectionNote = byContract.get(contract.id) ?? null; + } + } + async getStatusCounts(): Promise> { const rows = await this.repository .createQueryBuilder('contract') @@ -368,6 +394,25 @@ export class ContractsRepository extends BaseRepository { }); } + /** + * Send-back reset: every step at or after `fromStepOrder` returns to PENDING + * with its actor/verdict cleared, so the chain re-runs from that stage. The + * send-back reason lives in the review-note trail, not on the wiped steps. + */ + async resetApprovalStepsFrom( + contractId: string, + fromStepOrder: number, + ): Promise { + await this.dataSource + .getRepository(ContractApprovalStep) + .createQueryBuilder() + .update() + .set({ status: 'PENDING', actedByStaffId: null, actedAt: null, note: null }) + .where('contract_id = :contractId', { contractId }) + .andWhere('step_order >= :fromStepOrder', { fromStepOrder }) + .execute(); + } + /** Check if all approval steps are approved. */ async allApprovalStepsComplete(contractId: string): Promise { const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ 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 b51403991..65f0637f0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -636,6 +636,47 @@ export class ContractsService { } } + // Surface the rejection reason. The approval-step note is wiped on + // send-back resets, so the review-note trail is the only durable source. + if (contract.status === 'REJECTED') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'REJECTION', + ); + contract.latestRejectionNote = note?.body ?? null; + } catch { + contract.latestRejectionNote = null; + } + } + + // Surface the send-back reason to the returned-to approver, but only while + // it is still actionable: once any step acts after the send-back the note + // is stale and stays out of the response (the trail keeps it in the DB). + if (contract.status === 'PENDING_APPROVAL') { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'STAFF_NOTE', + ); + // Stale when any step acted after it (send-back resolved) or when the + // chain itself is newer than the note (fresh cycle after a resubmit). + const staleAfter = Math.max( + 0, + ...(contract.approvalSteps ?? []).flatMap((s) => [ + s.actedAt ? new Date(s.actedAt).getTime() : 0, + s.createdAt ? new Date(s.createdAt).getTime() : 0, + ]), + ); + contract.latestSendBackNote = + note && new Date(note.createdAt).getTime() > staleAfter + ? note.body + : null; + } catch { + contract.latestSendBackNote = null; + } + } + return contract; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 173857159..9a86a5b4e 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, MinLength } from 'class-validator'; +import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ApproveStepDto { @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) @@ -26,6 +26,22 @@ export class RejectStepDto { @IsString() @MinLength(1) reason!: string; + + /** + * Where the rejection lands. Omitted → the customer: the contract goes to + * REJECTED and the customer must resubmit (unchanged legacy behaviour, and + * the only option for the first approver in the chain). Set to an EARLIER + * approved step's id → send-back: that step and everything after it reset to + * PENDING and the chain re-runs from there; the contract never leaves + * PENDING_APPROVAL and the customer is not involved. + */ + @ApiPropertyOptional({ + description: + 'Id of an earlier approval step to send the contract back to. Omit to reject to the customer.', + }) + @IsOptional() + @IsUUID() + returnToStepId?: string; } export class CancelContractDto { diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts index 6d2afce3c..2ece44f12 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts @@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [ 'CONTAINER_OPENED', 'CONTAINER_DAMAGED', 'FLUID_LEAKING', + 'OTHER', ] as const; export type IncidentType = (typeof INCIDENT_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b5e0b8fb1..4838d3f52 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -326,4 +326,19 @@ export class Contract extends BaseEntity { * asked them to fix. Lives in contract_review_notes, not a column here. */ latestChangeRequestNote?: string | null; + + /** + * Body of the most recent REJECTION review note, attached by + * ContractsService.findById when status is REJECTED so both backoffice and + * portal can show why. Lives in contract_review_notes, not a column here. + */ + latestRejectionNote?: string | null; + + /** + * Body of the most recent send-back STAFF_NOTE, attached by + * ContractsService.findById while the contract is PENDING_APPROVAL and no + * approval step has acted since the send-back. Lives in + * contract_review_notes, not a column here. + */ + latestSendBackNote?: string | null; } diff --git a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 0143f0796..948853e22 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; @@ -66,6 +67,7 @@ export class FirstMileService { for (const r of records) { (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'FIRST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index b31a2ecbb..601e03aec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -12,6 +12,7 @@ import { SELF_HAUL_CONFLICT_MESSAGE, usesEdrMileService, } from '../../common/mile-haulage.util'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -88,6 +89,7 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto @@ -333,6 +335,8 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> > { const [lm] = await this.lastMileRepository.findAll({ @@ -347,9 +351,11 @@ export class LastMileService { ? lm.vehicleAssignments.map((va) => ({ vehicle: va.vehicle, containerNumber: va.containerNumber ?? null, + arrivedAt: va.arrivedAt ?? null, + departedAt: va.departedAt ?? null, })) : lm.vehicle - ? [{ vehicle: lm.vehicle, containerNumber: null }] + ? [{ vehicle: lm.vehicle, containerNumber: null, arrivedAt: null, departedAt: null }] : []; const out: Array<{ @@ -361,8 +367,10 @@ export class LastMileService { driverPhone: string | null; truckType: string | null; containerNumber: string | null; + arrivedAt: string | null; + departedAt: string | null; }> = []; - for (const { vehicle, containerNumber } of sources) { + for (const { vehicle, containerNumber, arrivedAt, departedAt } of sources) { if (!vehicle) continue; let driverName = vehicle.assignedDriverName ?? null; let driverLicense: string | null = null; @@ -386,6 +394,8 @@ export class LastMileService { driverPhone, truckType: vehicle.vehicleType || null, containerNumber, + arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null, + departedAt: departedAt ? new Date(departedAt).toISOString() : null, }); } return out; diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts index c634d5efb..a42dcd220 100644 --- a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsIn, IsOptional, IsUUID } from 'class-validator'; import { LOCOMOTIVE_STATUSES, @@ -21,4 +22,29 @@ export class FilterLocomotivesDto { @IsOptional() @IsUUID() currentYardId?: string; + + /** + * Drop locomotives already coupled to a built train — the train-builder + * "change locomotives" picker uses this so a loco that belongs to another + * train is never offered (the backend would 409 on save anyway). Combine with + * `excludeTrainId` to keep the CURRENT train's own locos in the list. + */ + @ApiPropertyOptional({ + description: 'Exclude locomotives already coupled to any built train', + }) + @IsOptional() + @Transform(({ value }) => value === true || value === 'true') + @IsBoolean() + excludeCoupled?: boolean; + + /** + * When `excludeCoupled` is set, locos coupled to THIS train are still kept + * (they are valid picks — you are editing that train's consist). + */ + @ApiPropertyOptional({ + description: 'Train id whose own coupled locomotives are NOT excluded', + }) + @IsOptional() + @IsUUID() + excludeTrainId?: string; } diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts index 18a42205e..3ad5b3650 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -3,7 +3,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Locomotive } from './entities/locomotive.entity'; +import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity'; +import { TrainLocomotive } from '../trains/entities/train-locomotive.entity'; @Injectable() export class LocomotivesRepository extends BaseRepository { @@ -14,6 +15,47 @@ export class LocomotivesRepository extends BaseRepository { super(repository); } + /** + * List locomotives for the train-builder coupling picker: the usual + * status/type/yard filters, plus optional exclusion of any loco already + * coupled to a built train. `keepTrainId` spares that one train's own locos + * from the exclusion so they stay selectable while editing its consist. + */ + findForCoupling(opts: { + status?: LocomotiveStatus; + locomotiveType?: LocomotiveType; + currentYardId?: string; + excludeCoupled?: boolean; + keepTrainId?: string; + }): Promise { + const qb = this.repository + .createQueryBuilder('locomotive') + .leftJoinAndSelect('locomotive.currentYard', 'currentYard') + .orderBy('locomotive.code', 'ASC'); + + if (opts.status) qb.andWhere('locomotive.status = :status', { status: opts.status }); + if (opts.locomotiveType) + qb.andWhere('locomotive.locomotiveType = :type', { type: opts.locomotiveType }); + if (opts.currentYardId) + qb.andWhere('locomotive.currentYardId = :yardId', { yardId: opts.currentYardId }); + + if (opts.excludeCoupled) { + // NOT EXISTS a link to a DIFFERENT train. Own-train links are kept so the + // consist being edited still lists its current locomotives. + const sub = this.repository.manager + .getRepository(TrainLocomotive) + .createQueryBuilder('tl') + .select('1') + .where('tl.locomotiveId = locomotive.id'); + if (opts.keepTrainId) { + sub.andWhere('tl.trainId != :keepTrainId', { keepTrainId: opts.keepTrainId }); + } + qb.andWhere(`NOT EXISTS (${sub.getQuery()})`).setParameters(sub.getParameters()); + } + + return qb.getMany(); + } + /** * A live locomotive already holding this name, compared the same way the * `UQ_locomotives_name_active` index compares: case- and whitespace- diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts index cbf9dfc0c..46e0ae415 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -29,6 +29,17 @@ export class LocomotivesService { } findAll(filter: FilterLocomotivesDto): Promise { + // The coupling picker needs a NOT-EXISTS against the train link table, so it + // takes the query-builder path; the plain list keeps the simple where. + if (filter.excludeCoupled) { + return this.locomotivesRepository.findForCoupling({ + status: filter.status as LocomotiveStatus | undefined, + locomotiveType: filter.locomotiveType as LocomotiveType | undefined, + currentYardId: filter.currentYardId, + excludeCoupled: true, + keepTrainId: filter.excludeTrainId, + }); + } return this.locomotivesRepository.findAll({ where: { ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}), diff --git a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts index 3f4d2e4ff..3de24835a 100644 --- a/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts +++ b/apps/edr-freight-api/src/modules/routes/dto/create-route.dto.ts @@ -4,25 +4,22 @@ import { ArrayMinSize, IsArray, IsEnum, - IsNumber, IsOptional, IsUUID, - Min, ValidateNested, } from 'class-validator'; import { RouteStatus } from '../entities/route.entity'; +/** + * Segment distances are no longer part of the payload — they are resolved + * from the configured yard_distances table (Configuration → Yard Distances) + * and snapshotted onto route_milestones at create/update. + */ export class CreateRouteMilestoneDto { @ApiProperty({ format: 'uuid' }) @IsUUID() yardId!: string; - - @ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' }) - @IsOptional() - @IsNumber() - @Min(0) - distanceKm?: number; } export class CreateRouteDto { diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 4b1bfd08a..96e6c7fd1 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -9,6 +9,7 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { YardDistance } from '../rule-engine/entities/yard-distance.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; @@ -17,6 +18,9 @@ import { RouteMilestone } from './entities/route-milestone.entity'; import { formatRouteLabel, Route } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; +/** Order-insensitive key: distances are symmetric. */ +const pairKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`); + @Injectable() export class RoutesService { constructor( @@ -183,47 +187,63 @@ export class RoutesService { return this.findById(id); } - private async validateMilestones( - milestones: Array<{ yardId: string; distanceKm?: number }>, - ) { + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); } - const normalized = milestones.map((milestone, index) => { - const distanceKm = - index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null; - if (index > 0 && (distanceKm == null || distanceKm < 0)) { - throw new BadRequestException( - `Enter segment KM for stop ${index + 1} (from previous yard).`, - ); - } - return { - yardId: milestone.yardId, - sequenceNo: index + 1, - distanceKm, - }; - }); - - const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; + const uniqueYardIds = [...new Set(milestones.map((milestone) => milestone.yardId))]; const yards = await this.dataSource .getRepository(Yard) .find({ where: uniqueYardIds.map((id) => ({ id })) }); const yardIds = new Set(yards.map((yard) => yard.id)); - for (const milestone of normalized) { + for (const milestone of milestones) { if (!yardIds.has(milestone.yardId)) { throw new BadRequestException(`Yard ${milestone.yardId} does not exist`); } } - if (normalized[0].yardId === normalized[normalized.length - 1].yardId) { + if (milestones[0].yardId === milestones[milestones.length - 1].yardId) { throw new BadRequestException('Origin and destination yards must be different'); } - const originYardId = normalized[0].yardId; - const destinationYardId = normalized[normalized.length - 1].yardId; const yardById = new Map(yards.map((yard) => [yard.id, yard])); + const distanceByPair = await this.loadDistanceLookup(uniqueYardIds); + + // Segment km come from the configured yard-distance table, not the payload + // — a route can only be built over pairs an admin has entered. Distances + // are symmetric, so an A→B row also serves B→A. + const missingPairs: string[] = []; + const normalized = milestones.map((milestone, index) => { + if (index === 0) { + return { yardId: milestone.yardId, sequenceNo: 1, distanceKm: 0 }; + } + const previousYardId = milestones[index - 1].yardId; + const distanceKm = distanceByPair.get(pairKey(previousYardId, milestone.yardId)); + if (distanceKm == null) { + const from = yardById.get(previousYardId); + const to = yardById.get(milestone.yardId); + missingPairs.push( + `${from?.label ?? previousYardId} ↔ ${to?.label ?? milestone.yardId}`, + ); + } + return { + yardId: milestone.yardId, + sequenceNo: index + 1, + distanceKm: distanceKm ?? null, + }; + }); + + if (missingPairs.length > 0) { + throw new BadRequestException( + `No distance configured for: ${missingPairs.join(', ')}. ` + + 'Add the missing yard distances in Configuration → Yard Distances first.', + ); + } + + const originYardId = milestones[0].yardId; + const destinationYardId = milestones[milestones.length - 1].yardId; const direction = deriveTradeDirection( yardById.get(originYardId) ?? { country: null }, yardById.get(destinationYardId) ?? { country: null }, @@ -236,4 +256,17 @@ export class RoutesService { milestones: normalized, }; } + + /** Order-insensitive pair → km map over every configured distance touching the yards. */ + private async loadDistanceLookup(yardIds: string[]): Promise> { + const rows = await this.dataSource + .getRepository(YardDistance) + .find({ where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }] }); + + const lookup = new Map(); + for (const row of rows) { + lookup.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm)); + } + return lookup; + } } diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index 421e49165..3b1bf6ce1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -3,7 +3,8 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; @@ -18,7 +19,7 @@ export class CargoTypesController { constructor(private readonly service: CargoTypesService) {} @Get() - @RuleEngineView('cargo-types') + @StaffReference() @ApiOperation({ summary: 'List cargo types' }) findAll(@Query() query: ListCargoTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class CargoTypesController { } @Get(':id') - @RuleEngineView('cargo-types') + @StaffReference() @ApiOperation({ summary: 'Get a cargo type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 3c1c27c7c..9ae96af9b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -3,7 +3,8 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; @@ -18,7 +19,7 @@ export class ContainerTypesController { constructor(private readonly service: ContainerTypesService) {} @Get() - @RuleEngineView('container-types') + @StaffReference() @ApiOperation({ summary: 'List container types' }) findAll(@Query() query: ListContainerTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class ContainerTypesController { } @Get(':id') - @RuleEngineView('container-types') + @StaffReference() @ApiOperation({ summary: 'Get a container type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index c36d4fd79..85d76b326 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -18,7 +19,7 @@ export class ServiceTypesController { constructor(private readonly service: ServiceTypesService) {} @Get() - @RuleEngineView('service-types') + @StaffReference() @ApiOperation({ summary: 'List service types' }) findAll(@Query() query: ListServiceTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class ServiceTypesController { } @Get(':id') - @RuleEngineView('service-types') + @StaffReference() @ApiOperation({ summary: 'Get a service type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index baec6b785..f078624eb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -16,14 +17,14 @@ export class ShippingLinesController { constructor(private readonly service: ShippingLinesService) {} @Get() - @RuleEngineView('shipping-lines') + @StaffReference() @ApiOperation({ summary: 'List shipping lines' }) findAll(@Query() query: ListRuleEngineQueryDto) { return this.service.findAll(query); } @Get(':id') - @RuleEngineView('shipping-lines') + @StaffReference() @ApiOperation({ summary: 'Get a shipping line by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts new file mode 100644 index 000000000..b0ba2c8d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistancesService } from '../services/yard-distances.service'; + +@ApiTags('yard-distances') +@Controller('yard-distances') +@ApiBearerAuth() +export class YardDistancesController { + constructor(private readonly service: YardDistancesService) {} + + @Get() + @StaffReference() + @ApiOperation({ summary: 'List yard distances' }) + findAll(@Query() query: ListYardDistancesQueryDto) { + return this.service.findAll(query); + } + + @Get(':id') + @StaffReference() + @ApiOperation({ summary: 'Get a yard distance by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post() + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Create a yard distance' }) + create(@Body() dto: CreateYardDistanceDto) { + return this.service.create(dto); + } + + @Patch(':id') + @RuleEngineManage('yard-distances') + @ApiOperation({ summary: 'Update a yard distance' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDistanceDto) { + return this.service.update(id, dto); + } + + @Delete(':id') + @RuleEngineManage('yard-distances') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a yard distance' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 40b2764bf..b8f88b6b3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -18,7 +19,9 @@ export class YardsController { constructor(private readonly service: YardsService) {} @Get() - @RuleEngineView('yards') + // Reference read: every staff form/search needs the yard list (origin / + // destination pickers), so login is the only requirement. + @StaffReference() @ApiOperation({ summary: 'List yards' }) findAll(@Query() query: ListYardsQueryDto) { return this.service.findAll(query); @@ -41,7 +44,7 @@ export class YardsController { } @Get(':id') - @RuleEngineView('yards') + @StaffReference() @ApiOperation({ summary: 'Get a yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts new file mode 100644 index 000000000..0615debdc --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-yard-distance.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, IsUUID, Min } from 'class-validator'; + +const toNumber = ({ value }: { value: unknown }) => + value === '' || value == null ? value : Number(value); + +export class CreateYardDistanceDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + fromYardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + toYardId!: string; + + @ApiProperty({ description: 'Rail distance between the two yards in kilometres', example: 445 }) + @Transform(toNumber) + @IsNumber() + @Min(0.01) + distanceKm!: number; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index 5718b0531..30241ddaa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -88,6 +88,18 @@ export class ListYardsQueryDto extends ListRuleEngineQueryDto { sortBy?: string; } +export class ListYardDistancesQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ description: 'Return only distances touching this yard.' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ enum: ['createdAt', 'distanceKm'], default: 'createdAt' }) + @IsOptional() + @IsIn(['createdAt', 'distanceKm']) + sortBy?: string; +} + export class ListApprovalRulesQueryDto extends PaginationQueryDto { @ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts new file mode 100644 index 000000000..8c40876ec --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/update-yard-distance.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/mapped-types'; + +import { CreateYardDistanceDto } from './create-yard-distance.dto'; + +export class UpdateYardDistanceDto extends PartialType(CreateYardDistanceDto) {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts new file mode 100644 index 000000000..982079f47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-distance.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from './yard.entity'; + +/** + * Configured rail distance between two yards. Route creation reads segment + * kilometres from here (symmetric: A→B serves B→A too) instead of taking + * them as free-text input — see RoutesService.validateMilestones. + * + * Uniqueness on (from_yard_id, to_yard_id) is a partial index in the DB + * (WHERE deleted_at IS NULL) rather than a @Unique decorator, so a + * soft-deleted pair can be re-created. + */ +@Entity({ schema: 'freight', name: 'yard_distances' }) +@Index(['fromYardId']) +@Index(['toYardId']) +export class YardDistance extends BaseEntity { + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard; + + @Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2 }) + distanceKm!: string; // decimal columns come back as string in typeorm/pg — keep consistent with RouteMilestone.distanceKm +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts new file mode 100644 index 000000000..ca88ae048 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/yard-distances.repository.interface.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from '@edr/types'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; + +export interface IYardDistancesRepository { + findById(id: string): Promise; + /** Exact or reverse pair — distances are symmetric (A→B serves B→A). */ + findBetween(fromYardId: string, toYardId: string): Promise; + /** All rows touching any of the given yards, for batch segment lookups. */ + findTouchingYards(yardIds: string[]): Promise; + findPaged(query: ListYardDistancesQueryDto): Promise>; + create(data: Partial): Promise; + update(id: string, data: Partial): Promise; + softDelete(id: string): Promise; +} + +export const YARD_DISTANCES_REPOSITORY = Symbol('YARD_DISTANCES_REPOSITORY'); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts new file mode 100644 index 000000000..379c92c7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/yard-distances.repository.ts @@ -0,0 +1,87 @@ +import { PaginatedResponse } from '@edr/types'; +import { Injectable } from '@nestjs/common'; +import { Brackets, DataSource, In, Repository } from 'typeorm'; +import { paginateQuery } from '../../../common/utils/pagination.util'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { IYardDistancesRepository } from '../interfaces/yard-distances.repository.interface'; + +@Injectable() +export class YardDistancesRepository implements IYardDistancesRepository { + private readonly repo: Repository; + + constructor(private readonly dataSource: DataSource) { + this.repo = this.dataSource.getRepository(YardDistance); + } + + findById(id: string): Promise { + return this.repo.findOne({ + where: { id }, + relations: { fromYard: true, toYard: true }, + }); + } + + findBetween(fromYardId: string, toYardId: string): Promise { + return this.repo.findOne({ + where: [ + { fromYardId, toYardId }, + { fromYardId: toYardId, toYardId: fromYardId }, + ], + }); + } + + findTouchingYards(yardIds: string[]): Promise { + if (!yardIds.length) return Promise.resolve([]); + return this.repo.find({ + where: [{ fromYardId: In(yardIds) }, { toYardId: In(yardIds) }], + }); + } + + /** Paged list with server-side search on either yard's label/code. */ + findPaged(query: ListYardDistancesQueryDto): Promise> { + const qb = this.repo + .createQueryBuilder('yardDistance') + .leftJoinAndSelect('yardDistance.fromYard', 'fromYard') + .leftJoinAndSelect('yardDistance.toYard', 'toYard') + .orderBy(`yardDistance.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'ASC') + .addOrderBy('fromYard.label', 'ASC'); + + if (query.yardId) { + qb.andWhere( + new Brackets((w) => + w + .where('yardDistance.fromYardId = :yardId', { yardId: query.yardId }) + .orWhere('yardDistance.toYardId = :yardId', { yardId: query.yardId }), + ), + ); + } + if (query.search) { + qb.andWhere( + new Brackets((w) => + w + .where('fromYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('fromYard.code ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.label ILIKE :search', { search: `%${query.search}%` }) + .orWhere('toYard.code ILIKE :search', { search: `%${query.search}%` }), + ), + ); + } + + return paginateQuery(qb, query); + } + + async create(data: Partial): Promise { + const entity = this.repo.create(data); + const saved = await this.repo.save(entity); + return (await this.findById(saved.id)) ?? saved; + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findById(id); + } + + async softDelete(id: string): Promise { + await this.repo.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 95b5e381d..691992c54 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -11,6 +11,7 @@ import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; import { WeightLimitRulesController } from './controllers/weight-limit-rules.controller'; +import { YardDistancesController } from './controllers/yard-distances.controller'; import { YardsController } from './controllers/yards.controller'; import { ApprovalRule } from './entities/approval-rule.entity'; @@ -24,6 +25,7 @@ import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; import { WeightLimitRule } from './entities/weight-limit-rule.entity'; import { Yard } from './entities/yard.entity'; +import { YardDistance } from './entities/yard-distance.entity'; import { YardFacility } from './entities/yard-facility.entity'; import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface'; @@ -34,6 +36,7 @@ import { RATES_REPOSITORY } from './interfaces/rates.repository.interface'; import { SERVICE_TYPES_REPOSITORY } from './interfaces/service-types.repository.interface'; import { SHIPPING_LINES_REPOSITORY } from './interfaces/shipping-lines.repository.interface'; import { WEIGHT_LIMIT_RULES_REPOSITORY } from './interfaces/weight-limit-rules.repository.interface'; +import { YARD_DISTANCES_REPOSITORY } from './interfaces/yard-distances.repository.interface'; import { YARDS_REPOSITORY } from './interfaces/yards.repository.interface'; import { ApprovalRulesRepository } from './repositories/approval-rules.repository'; @@ -44,6 +47,7 @@ import { RatesRepository } from './repositories/rates.repository'; import { ServiceTypesRepository } from './repositories/service-types.repository'; import { ShippingLinesRepository } from './repositories/shipping-lines.repository'; import { WeightLimitRulesRepository } from './repositories/weight-limit-rules.repository'; +import { YardDistancesRepository } from './repositories/yard-distances.repository'; import { YardsRepository } from './repositories/yards.repository'; import { ApprovalRulesService } from './services/approval-rules.service'; @@ -58,6 +62,7 @@ import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; import { WeightLimitRulesService } from './services/weight-limit-rules.service'; import { YardsService } from './services/yards.service'; +import { YardDistancesService } from './services/yard-distances.service'; import { YardFacilitiesService } from './services/yard-facilities.service'; import { RuleEngineService } from './rule-engine.service'; @@ -80,6 +85,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceType, WeightLimitRule, Yard, + YardDistance, YardFacility, ShippingLine, Rate, @@ -100,6 +106,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesController, WeightLimitRulesController, YardsController, + YardDistancesController, ShippingLinesController, RatesController, ApprovalRulesController, @@ -117,6 +124,8 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. { provide: WEIGHT_LIMIT_RULES_REPOSITORY, useExisting: WeightLimitRulesRepository }, YardsRepository, { provide: YARDS_REPOSITORY, useExisting: YardsRepository }, + YardDistancesRepository, + { provide: YARD_DISTANCES_REPOSITORY, useExisting: YardDistancesRepository }, ShippingLinesRepository, { provide: SHIPPING_LINES_REPOSITORY, useExisting: ShippingLinesRepository }, RatesRepository, @@ -131,6 +140,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. ServiceTypesService, WeightLimitRulesService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -146,6 +156,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. WeightLimitRulesService, PriorityConfigsService, YardsService, + YardDistancesService, YardFacilitiesService, ShippingLinesService, RatesService, @@ -155,6 +166,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. SERVICE_TYPES_REPOSITORY, SHIPPING_LINES_REPOSITORY, YARDS_REPOSITORY, + YARD_DISTANCES_REPOSITORY, ], }) export class RuleEngineModule {} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts new file mode 100644 index 000000000..85c7db4a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -0,0 +1,78 @@ +import { RuleEngineService } from './rule-engine.service'; +import type { BookingEvaluationInput } from './rule-engine.service'; +import type { Rate } from './entities/rate.entity'; + +describe('RuleEngineService — requested service without a configured surcharge rate', () => { + const hazardRate: Rate = { + id: 'rate-hazard', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + let ratesRepo: { findLiveRates: jest.Mock }; + let service: RuleEngineService; + + beforeEach(() => { + ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) }; + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes + { findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits + { findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs + ratesRepo as never, + { findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines + {} as never, // dataSource (unused by evaluate) + ); + }); + + const input = (overrides: Partial): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + containers: [], + ...overrides, + }); + + it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => { + const result = await service.evaluate(input({ isHazardous: true })); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); + + it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => { + ratesRepo.findLiveRates.mockResolvedValue([hazardRate]); + const result = await service.evaluate(input({ isHazardous: true })); + expect(result.hardBlocked).toHaveLength(0); + }); + + it('does not block a non-hazardous booking when no surcharge rates exist', async () => { + const result = await service.evaluate(input({})); + expect(result.hardBlocked).toHaveLength(0); + }); + + it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => { + const result = await service.evaluate( + input({ + containers: [ + { + containerTypeId: 'ct-20', + quantity: 2, + vgmPerUnitTons: 10, + totalVgmTons: 20, + reeferQuantity: 1, + }, + ], + }), + ); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('reefer'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 9d7aa6ba9..3a4c4b778 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -28,6 +28,10 @@ import { } from './interfaces/shipping-lines.repository.interface'; import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; +// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. +// from multipart form-data) and a non-empty "false" string is truthy. +const truthy = (v: unknown): boolean => v === true || v === 'true'; + export interface BookingContainerEvalInput { containerTypeId: string; quantity: number; @@ -245,6 +249,48 @@ export class RuleEngineService { liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), ); + // A handling service the booking asks for (booking-level flag OR any + // per-container opt-in count) with no LIVE surcharge rate configured is a + // hard block — pricing would otherwise ship the service for free. System- + // derived charges (consolidation, overweight, shipping line, lashing) stay + // exempt: the customer never opted into those, so they must not block. + const requestedServices: Array<{ + trigger: RateTrigger; + wanted: boolean; + label: string; + }> = [ + { + trigger: 'HAZARDOUS', + wanted: + truthy(input.isHazardous) || + input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0), + label: 'hazardous cargo', + }, + { + trigger: 'REEFER', + wanted: + hasReefer || + input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), + label: 'refrigerated (reefer) cargo', + }, + { + trigger: 'WITH_RETURN', + wanted: + truthy(input.withReturn) || + input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0), + label: 'empty-container return', + }, + ]; + for (const svc of requestedServices) { + if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { + hardBlocked.push( + `No ${svc.label} surcharge rate is configured — the booking cannot ` + + `be priced with this service. Remove the ${svc.label} option or ` + + 'ask EDR to configure its rate.', + ); + } + } + for (const rate of surchargeRates) { const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, @@ -438,9 +484,6 @@ export class RuleEngineService { hasLashing: boolean; }, ): boolean { - // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. - // from multipart form-data) and a non-empty "false" string is truthy. - const truthy = (v: unknown): boolean => v === true || v === 'true'; switch (trigger) { case 'HAZARDOUS': return truthy(state.isHazardous); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts new file mode 100644 index 000000000..a41e593e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-distances.service.ts @@ -0,0 +1,119 @@ +import { PaginatedResponse } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; +import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; +import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; +import { YardDistance } from '../entities/yard-distance.entity'; +import { + IYardDistancesRepository, + YARD_DISTANCES_REPOSITORY, +} from '../interfaces/yard-distances.repository.interface'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; + +/** + * Flat row shape for the backoffice config table: the yard relations stay for + * API consumers, plus label fields the generic rule-engine grid can render. + */ +export type YardDistanceRow = YardDistance & { + fromYardLabel: string; + toYardLabel: string; +}; + +const yardDisplay = (yard?: { label?: string; code?: string } | null): string => + yard?.label ?? yard?.code ?? '—'; + +const toRow = (entity: YardDistance): YardDistanceRow => + Object.assign(entity, { + fromYardLabel: yardDisplay(entity.fromYard), + toYardLabel: yardDisplay(entity.toYard), + }); + +@Injectable() +export class YardDistancesService { + constructor( + @Inject(YARD_DISTANCES_REPOSITORY) + private readonly repository: IYardDistancesRepository, + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, + ) {} + + async findAll(query: ListYardDistancesQueryDto): Promise> { + const page = await this.repository.findPaged(query); + return { ...page, items: page.items.map(toRow) }; + } + + async findById(id: string): Promise { + const entity = await this.repository.findById(id); + if (!entity) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(entity); + } + + async create(dto: CreateYardDistanceDto): Promise { + await this.assertValidPair(dto.fromYardId, dto.toYardId); + + const created = await this.repository.create({ + fromYardId: dto.fromYardId, + toYardId: dto.toYardId, + distanceKm: dto.distanceKm.toFixed(2), + }); + return toRow(created); + } + + async update(id: string, dto: UpdateYardDistanceDto): Promise { + const existing = await this.findById(id); + + const fromYardId = dto.fromYardId ?? existing.fromYardId; + const toYardId = dto.toYardId ?? existing.toYardId; + if (fromYardId !== existing.fromYardId || toYardId !== existing.toYardId) { + await this.assertValidPair(fromYardId, toYardId, id); + } + + const updated = await this.repository.update(id, { + fromYardId, + toYardId, + ...(dto.distanceKm != null ? { distanceKm: dto.distanceKm.toFixed(2) } : {}), + }); + if (!updated) throw new NotFoundException(`Yard distance ${id} not found`); + return toRow(updated); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.repository.softDelete(id); + } + + /** + * Both yards must exist and differ, and the pair must not already be + * configured in either direction — distances are symmetric, so an A→B row + * already covers B→A. + */ + private async assertValidPair( + fromYardId: string, + toYardId: string, + ignoreId?: string, + ): Promise { + if (fromYardId === toYardId) { + throw new BadRequestException('From and to yards must be different'); + } + + const [fromYard, toYard] = await Promise.all([ + this.yardsRepository.findById(fromYardId), + this.yardsRepository.findById(toYardId), + ]); + if (!fromYard) throw new BadRequestException(`Yard ${fromYardId} does not exist`); + if (!toYard) throw new BadRequestException(`Yard ${toYardId} does not exist`); + + const existing = await this.repository.findBetween(fromYardId, toYardId); + if (existing && existing.id !== ignoreId) { + throw new ConflictException( + `A distance between ${fromYard.label} and ${toYard.label} is already configured`, + ); + } + } +} 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 fc97f772b..bafc7a13e 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 @@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => { }); }); + describe('expireLeftoverExportDay — export day sweep', () => { + const exportSchedule = { + id: scheduleId, + direction: 'EXPORT', + originStationId: 'yard-origin', + destinationStationId: 'yard-dest', + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + windowPhase: 'DONE', + bookingWindowStatus: 'CLOSED', + }; + let unacceptedSpy: jest.SpyInstance; + let poolSpy: jest.SpyInstance; + + beforeEach(() => { + unacceptedSpy = jest + .spyOn(service, 'expireUnacceptedForRouteDay') + .mockResolvedValue(undefined); + poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0); + }); + + it('ignores non-export schedules', async () => { + trainSchedulesRepository.findById.mockResolvedValue({ + ...exportSchedule, + direction: 'IMPORT', + }); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('defers while another export train on the day can still take bookings', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('defers while a FULL train still has live pay windows', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'FULL', + }, + ]); + bookingsRepository.findReservedForSchedule.mockResolvedValue([ + { + paymentStatus: 'PENDING', + status: 'AWAITING_PAYMENT', + paymentDeadline: new Date(Date.now() + 60_000), + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'FULL', + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).toHaveBeenCalledWith({ + originYardId: 'yard-origin', + destinationYardId: 'yard-dest', + day: '2026-06-20', + }); + expect(poolSpy).toHaveBeenCalledWith(scheduleId); + }); + }); + describe('maybeOfferPartial — split-eligibility gate', () => { const importGeneral = { id: 'b1', @@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => { ); }); }); + + describe('acceptIntercity — export pay window expires at window close', () => { + const exportScheduleId = 'export-train'; + // Window closes in 30 minutes; the configured pay window is 60 minutes. + const closesAt = new Date(Date.now() + 30 * 60_000); + + const waiting = { + id: 'ic-1', + reference: 'IC-1', + isGovernment: false, + status: 'FULLY_EXECUTED', + trainScheduleId: null, + freightType: 'CONTAINER', + cargoTotalWeightVgm: 10, + bookingContainers: [], + } as unknown as Booking; + + let scheduleRepo: { findOne: jest.Mock }; + let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock }; + + beforeEach(() => { + bookingRepo = dataSource.getRepository(); + bookingRepo.findOne.mockResolvedValue(waiting); + scheduleRepo = { findOne: jest.fn() }; + // reserve() reads the target schedule to clamp export deadlines — route + // TrainSchedule reads to their own repo, everything else stays as before. + dataSource.getRepository.mockImplementation((entity?: { name?: string }) => + entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo, + ); + }); + + it('clamps the intercity pay deadline to the export window close', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: exportScheduleId, + direction: 'EXPORT', + windowClosesAt: closesAt, + scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000), + }); + + await service.acceptIntercity(waiting, exportScheduleId); + + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'ic-1', + expect.objectContaining({ + status: 'SELECTED_FOR_BATCH', + paymentDeadline: closesAt, + }), + ); + expect(notifier.payNow).toHaveBeenCalledTimes(1); + }); + + it('keeps the plain payment window on import trains', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: 'import-train', + direction: 'IMPORT', + windowClosesAt: closesAt, + }); + + await service.acceptIntercity(waiting, 'import-train'); + + const deadline = ( + bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date } + ).paymentDeadline; + // 60-minute pay window runs past the 30-minutes-out close: no clamp. + expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime()); + }); + + it('rejects an accept after the export window closed — no pay window opens', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: exportScheduleId, + direction: 'EXPORT', + windowClosesAt: new Date(Date.now() - 60_000), + }); + + await expect( + service.acceptIntercity(waiting, exportScheduleId), + ).rejects.toThrow(/window has closed/); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + expect(notifier.payNow).not.toHaveBeenCalled(); + }); + + it('expires an unpaid export ride-along at close and frees the train', async () => { + const lapsed = { + ...(waiting as unknown as Record), + status: 'SELECTED_FOR_BATCH', + trainScheduleId: exportScheduleId, + paymentDeadline: new Date(Date.now() - 1_000), + originYardId: 'yard-a', + destinationYardId: 'yard-b', + priorityScore: 0, + wagonsRequired: 1, + } as unknown as Booking; + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([lapsed]) + .mockResolvedValue([]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + // expire()'s paid-guard re-reads the booking fresh — still unpaid. + bookingRepo.findOne.mockResolvedValue(lapsed); + trainSchedulesRepository.findById.mockResolvedValue({ + id: exportScheduleId, + bookingWindowStatus: 'CLOSED', + windowPhase: 'DONE', + scheduledDepartureDate: new Date(Date.now() + 3_600_000), + originStationId: 'yard-a', + destinationStationId: 'yard-b', + }); + + await service.settleDueReservations(exportScheduleId); + + expect(notifier.expired).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'ic-1', + expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }), + ); + }); + }); }); describe('BookingBatchService — wagonsFor', () => { 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 415b5df97..03a125aef 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 @@ -570,6 +570,10 @@ export class BookingBatchService implements OnModuleInit { ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); + // This payment may have been the last live pay window on a now-full + // export day — the settle that normally re-runs the sweep finds nothing + // left to settle, so trigger it here. + void this.expireLeftoverExportDay(booking.trainScheduleId); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -2254,6 +2258,11 @@ export class BookingBatchService implements OnModuleInit { `— payment phase extended for them`, ); } + // The settle may have resolved the last pay window on a full export day + // (paid → allocated, and the top-up found nothing else that fits) — sweep + // the date's leftover bookings. Self-guarded: no-op for import/domestic + // and while any train on the day can still take bookings. + await this.expireLeftoverExportDay(scheduleId); // Emitted here (not in settleDueReservations/settleBatch, which both wrap // this) so one settle produces one push, after every allocation/expiry/ // top-up extension for this schedule has been persisted. @@ -2350,6 +2359,9 @@ export class BookingBatchService implements OnModuleInit { ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); + // Same as the webhook path: a staff mark-paid can settle the last live + // pay window on a now-full export day — sweep the date's leftovers. + void this.expireLeftoverExportDay(booking.trainScheduleId); } void this.triggerWagonAllocation(booking.trainScheduleId!); this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid"); @@ -2461,13 +2473,13 @@ export class BookingBatchService implements OnModuleInit { if (!schedule || !locomotive) return null; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); - // Built trains: collapse to a single train-wide pool so the freed capacity of - // a booking that alights mid-corridor is NOT re-offered on the pass-through - // leg (see remainingBudget). Keeps intercity accept consistent with the - // train-wide isTrainFull / committedWagons finalize signal. - const budget = await this.remainingBudget(schedule, limits, wagonDims, { - collapseForBuiltTrain: true, - }); + // Built trains use the leg-aware corridor budget too: the wagon planner + // consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon + // that runs empty Gelan→Adama genuinely can carry an intercity booking + // there before its export cargo boards at Adama. A train full on one leg + // still accepts ride-alongs on its empty legs — that is the whole point + // of the ride-along flow. + const budget = await this.remainingBudget(schedule, limits, wagonDims); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } @@ -2526,7 +2538,26 @@ export class BookingBatchService implements OnModuleInit { return; } const now = new Date(); - const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + let deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + // EXPORT parity: pay windows on an export train never outlive its booking + // window — export bookings expire at close, so anything reserved onto the + // same train (FCFS export or an intercity ride-along) must too. Import + // keeps the plain payment window; its cycles re-fill after settle. + const targetSchedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (targetSchedule?.direction === "EXPORT") { + const cutoff = + targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate; + if (cutoff && cutoff.getTime() <= now.getTime()) { + throw new BadRequestException( + "Export booking window has closed — cannot open a pay window on this train", + ); + } + if (cutoff && cutoff.getTime() < deadline.getTime()) { + deadline = new Date(cutoff); + } + } await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", @@ -2822,6 +2853,60 @@ export class BookingBatchService implements OnModuleInit { return leftovers.length; } + /** + * EXPORT counterpart of the conclude-time sweep. Export has no batch cycle, + * so nothing ever concluded its day: bookings still waiting when the trains + * filled up or the window closed stayed pending forever. Once every export + * train on this route-day is shut — window DONE, or FULL with no pay window + * still live that could lapse and free space — the date is dead: expire the + * un-accepted bookings staff can no longer accept AND the ready + * (FULLY_EXECUTED) bookings that never got a reservation (consolidation + * waiters). Runs at export window close and whenever an export train's + * fullness settles. + */ + async expireLeftoverExportDay(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) { + return; + } + const day = eatDay(schedule.scheduledDepartureDate); + const trains = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }) + ).filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day, + ); + for (const s of trains) { + // Any train still taking bookings keeps the date alive. + if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return; + // A FULL train whose reservations are still inside their pay windows can + // reopen when one lapses unpaid — defer; the settle re-runs this sweep. + if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) { + return; + } + } + await this.expireUnacceptedForRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day, + }); + await this.expireLeftoverDayPool(scheduleId); + } + /** * Union of stop yards across the day's fillable schedules on this corridor — * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings @@ -3398,7 +3483,6 @@ export class BookingBatchService implements OnModuleInit { schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, - opts?: { collapseForBuiltTrain?: boolean }, ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { @@ -3411,21 +3495,10 @@ export class BookingBatchService implements OnModuleInit { tolerance: { weightTons: 0, lengthMeters: 0 }, }; } - // A built train's wagons are coupled for the WHOLE trip, and the allocator - // commits each booking to a wagon for the entire route — it never reloads a - // wagon at a mid-corridor alight yard. So a built train has no leg concept: - // its capacity is one train-wide pool, exactly as isTrainFull / - // committedWagons already count it. When a caller opts in, collapse the - // corridor to a single whole-route edge so every booking (full-route OR - // mid-corridor) draws from that one pool — a train full of import-to-DireDawa - // then correctly shows NO room for a DireDawa->Addis intercity booking on the - // leg it merely passes through, instead of over-promising the freed slots. - // Locomotive-derived schedules keep the leg-aware multi-edge corridor: their - // abstract slot/weight/length budget genuinely frees past an alight yard. - const stops = - physicalWagons != null && opts?.collapseForBuiltTrain - ? [schedule.originStationId, schedule.destinationStationId] - : await this.stopsForSchedule(schedule); + // Built trains keep the leg-aware multi-edge corridor too: the wagon + // planner consumes stock per edge (planWagonsWithStock legs), so a consist + // wagon serves disjoint legs — capacity freed past an alight yard is real. + const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index fb140158c..db18d6804 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -68,6 +69,9 @@ export class BookingJourneyService { } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin'); + // Export cargo must be in the warehouse with a GRN before it can be loaded, + // however it arrived and whatever it is allocated to. + await assertExportReceivedWithGrn(this.dataSource, booking); const now = new Date(); await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 9393c520f..abd07c129 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; expireLeftoverDayPool: jest.Mock; + expireLeftoverExportDay: jest.Mock; fillFromWaitingList: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; @@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), expireLeftoverDayPool: jest.fn().mockResolvedValue(0), + expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined), // No waiting booking fits by default, so conclude proceeds to reopen/DONE. fillFromWaitingList: jest.fn().mockResolvedValue(0), }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 02c5fb993..3b9bb25d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); schedule.bookingWindowStatus = 'CLOSED'; } + // Export has no conclude step: this close is the last moment the day's + // bookings could have boarded. Once every train on the route-day is + // shut, expire what is still waiting for this date (the sweep defers + // while a sibling train stays open). + await this.bookingBatchService.expireLeftoverExportDay(schedule.id); return true; } return false; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts new file mode 100644 index 000000000..a93a8afc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID } from 'class-validator'; + +export class MoveWagonLoadDto { + /** + * Where the source wagon's whole load goes: a train-set wagon slot (empty → + * move, loaded → swap the two loads) or an empty consist-only physical wagon + * of the built train (→ the slot repins onto it). + */ + @IsUUID() + targetWagonId!: string; +} 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 b35650e16..293fc8801 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 @@ -146,10 +146,8 @@ export class IntercityService { remaining: capacity?.budget.maxRemaining() ?? null, candidates: waiting.map((booking) => { const need = capacity?.needFor(booking) ?? null; - // legForYards, not legOf: on a built train the budget is a single - // whole-route edge (see intercityCapacity), so a mid-corridor booking - // must draw from that one pool via the whole-route fallback. On a - // locomotive-derived schedule it still resolves to the booking's own leg. + // legForYards: the booking draws only from ITS OWN leg's edges, with a + // whole-route fallback when its yards aren't on the budget's stop list. const leg = capacity?.budget.legForYards( booking.originYardId, booking.destinationYardId, @@ -215,11 +213,8 @@ export class IntercityService { continue; } const need = capacity.needFor(booking); - // legForYards, not legOf: a built train's budget is a single whole-route - // pool (mid-corridor wagons are committed for the whole trip and never - // reloaded), so the booking draws from that pool via the whole-route - // fallback; a locomotive-derived schedule still gets the booking's own - // leg, so it can still board a train that is full only on other legs. + // legForYards: charge only the edges this booking rides, so it can still + // board a train that is full only on other legs. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); if (!budget.fits(need, leg)) { rejected.push({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 3e142f778..b1f79733e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; +import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; @@ -374,6 +375,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.updateContainerItem(id, itemId, dto); } + @Post("schedules/:id/wagons/:wagonId/move-load") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", + }) + moveWagonLoad( + @Param("id", ParseUUIDPipe) id: string, + @Param("wagonId", ParseUUIDPipe) wagonId: string, + @Body() dto: MoveWagonLoadDto, + ) { + return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto); + } + @Get("schedules/:id/unassigned-bookings") @TrainSchedulingView() @ApiOperation({ summary: "Get unassigned bookings for a schedule" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index af1a9eb45..b6410d0cf 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -1081,4 +1081,172 @@ describe('TrainSchedulingService', () => { expect(html).not.toContain('EMPTY'); }); }); + + describe('moveWagonLoad — staff rearrange', () => { + const containerType = { + code: 'NX70', + supportedLoadTypes: ['CONTAINER'], + supportsContainer: true, + }; + let slotA: Record; + let slotB: Record; + let allocsByWagon: Record>>; + let allocRepo: { find: jest.Mock; update: jest.Mock }; + let slotRepo: { update: jest.Mock }; + let wagonRepo: { findOne: jest.Mock }; + + const makeSchedule = (over: Record = {}) => ({ + id: 'sched-1', + status: 'SCHEDULED', + trainSetId: 'ts-1', + trainSet: { trainId: 'train-1', wagons: [slotA, slotB] }, + ...over, + }); + + beforeEach(() => { + slotA = { + id: 'wA', + sequenceNo: 1, + capacityTons: 61, + lengthMeters: 14, + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + wagonType: containerType, + }; + slotB = { + id: 'wB', + sequenceNo: 2, + capacityTons: 61, + lengthMeters: 14, + assignedWeightTons: 25, + status: 'RESERVED', + boardYardId: null, + alightYardId: null, + wagonType: containerType, + }; + allocsByWagon = { + // 20ft pair (two allocations sharing wagon A) — must travel together. + wA: [ + { id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' }, + { id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' }, + ], + // one 40ft on wagon B. + wB: [ + { id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' }, + ], + }; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule()); + allocRepo = { + find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) => + Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []), + ), + update: jest.fn().mockResolvedValue(undefined), + }; + slotRepo = { update: jest.fn().mockResolvedValue(undefined) }; + wagonRepo = { findOne: jest.fn().mockResolvedValue(null) }; + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === WagonBookingAllocation) return allocRepo; + if (entity === TrainSetWagon) return slotRepo; + if (entity === Wagon) return wagonRepo; + return { find: jest.fn().mockResolvedValue([]) }; + }); + dataSource.transaction.mockImplementation( + async (fn: (m: unknown) => Promise) => + fn({ getRepository: dataSource.getRepository }), + ); + jest + .spyOn( + service as never as { getTrainScheduleById: (id: string) => Promise }, + 'getTrainScheduleById' as never, + ) + .mockResolvedValue({ id: 'sched-1' } as never); + }); + + it('rejects moves on a dispatched train', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + makeSchedule({ status: 'DISPATCHED' }), + ); + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(BadRequestException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('404s when the target is neither a slot nor a consist wagon of this train', async () => { + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }), + ).rejects.toThrow(/not part of this schedule/); + }); + + it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => { + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }); + + // The 20ft pair moved together onto wagon B… + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' }); + // …and the 40ft came back to wagon A. + expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' }); + // Load-coupled slot fields follow their loads. + expect(slotRepo.update).toHaveBeenCalledWith('wB', { + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + }); + expect(slotRepo.update).toHaveBeenCalledWith('wA', { + assignedWeightTons: 25, + status: 'RESERVED', + boardYardId: null, + alightYardId: null, + }); + }); + + it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => { + wagonRepo.findOne.mockResolvedValue({ + id: 'phys-9', + wagonTypeId: 'wt-1', + wagonNumber: 'WGN-9', + wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 }, + }); + + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' }); + + expect(wagonRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }), + ); + // Repin: wagon identity moves onto the slot; allocations stay put. + expect(slotRepo.update).toHaveBeenCalledWith('wA', { + physicalWagonId: 'phys-9', + wagonTypeId: 'wt-1', + capacityTons: 70, + lengthMeters: 14, + }); + expect(allocRepo.update).not.toHaveBeenCalled(); + }); + + it('rejects a bulk load onto a wagon whose type only supports containers', async () => { + allocsByWagon.wA = [ + { id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' }, + ]; + allocsByWagon.wB = []; + + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(/cannot carry a bulk load/); + }); + + it('rejects when the incoming load exceeds the receiving wagon payload', async () => { + allocsByWagon.wA = [ + { id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' }, + ]; + allocsByWagon.wB = []; + + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(/over its/); + }); + }); }); 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 2f7ca0776..13624691a 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 @@ -77,6 +77,7 @@ import { TrainScheduleFreightType, } from './dto/list-train-schedules-query.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { MoveWagonLoadDto } from './dto/move-wagon-load.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; @@ -122,7 +123,7 @@ import { sumWagonsRequired, type TrainLimitConfig, validateContainerPlacements, - validateMixedTrainLimits, + validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; @@ -1538,8 +1539,21 @@ export class TrainSchedulingService { } } + // The rebuild below deletes EVERY schedule↔booking link row and recreates + // only what makes the new plan. Ride-along (intercity) bookings are linked + // OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the + // workspace's picked ids, so planning from dto.bookingIds alone silently + // orphans them: PAID + SCHEDULED with no link and no wagon, invisible in + // every list. Every (re)assignment therefore re-plans the WHOLE train: + // the requested ids plus everything currently linked. + const linkedRows = + await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId); + const allBookingIds = [ + ...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]), + ]; + const previewDto = { - bookingIds: dto.bookingIds, + bookingIds: allBookingIds, scheduleDate: schedule.scheduledDepartureDate.toISOString(), originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, @@ -1562,8 +1576,13 @@ export class TrainSchedulingService { // preview the wagon plan first, then lay containers into the plan's slots. // Without this the placement validator rejects container bookings outright // ("Container placements are required for container bookings"). + // Callers hand-pick placements only for the bookings they know about; the + // union above may have folded in linked ride-alongs those placements never + // covered. Auto-fill whatever units are missing (all of them when no + // placements were sent at all) so the placement validator doesn't reject + // container bookings the caller couldn't have placed. let containerPlacements = dto.containerPlacements; - if (!containerPlacements?.length) { + { const preview = await this.validateBookingsForScheduling( previewDto, freightType ?? null, @@ -1578,18 +1597,28 @@ export class TrainSchedulingService { ); if (containerBookings.length) { const units = expandBookingContainerUnits(containerBookings); - const slots = getContainerSlotSequenceNos(preview.wagonPlan); - const generated = autoFillPlacements(units, slots); - const missing = findMissingContainerNumberIssues(units, generated); - if (missing.length) { - throw new BadRequestException({ - message: `Booking validation failed: ${missing - .map((m) => m.issue) - .join('; ')}`, - violations: missing.map((m) => m.issue), - }); + const providedKeys = new Set( + (containerPlacements ?? []).map( + (p) => `${p.bookingContainerId}:${p.unitIndex}`, + ), + ); + const unplacedUnits = units.filter( + (u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`), + ); + if (unplacedUnits.length) { + const slots = getContainerSlotSequenceNos(preview.wagonPlan); + const generated = autoFillPlacements(unplacedUnits, slots); + const missing = findMissingContainerNumberIssues(unplacedUnits, generated); + if (missing.length) { + throw new BadRequestException({ + message: `Booking validation failed: ${missing + .map((m) => m.issue) + .join('; ')}`, + violations: missing.map((m) => m.issue), + }); + } + containerPlacements = [...(containerPlacements ?? []), ...generated]; } - containerPlacements = generated; } } @@ -1632,8 +1661,10 @@ export class TrainSchedulingService { // NW5 free) — the caller saw HTTP 200 and a green toast over a no-op. // A stock shortage is a physical impossibility, so forceAssign cannot // override it either. + // Linked ride-alongs count as requested too: silently dropping one here is + // exactly the delete-and-recreate orphan this method must never produce. const plannedIds = new Set(validation.bookings.map((b) => b.id)); - const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id)); + const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id)); if (droppedRequested.length) { const reasonById = new Map( validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]), @@ -2632,6 +2663,11 @@ export class TrainSchedulingService { // dispatch pre-check keeps reporting these bookings as unloaded). const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); if (wagonAssignedIds.size) { + // Export cargo must be received at the warehouse with a GRN before it can + // be confirmed loaded — an allocation is not proof the goods are in hand. + if (this.isExportSchedule(schedule)) { + await this.assertExportBookingsReceived([...wagonAssignedIds]); + } await this.trainScheduleBookingsRepository.updateLoadingStatusMany( scheduleId, [...wagonAssignedIds], @@ -2909,6 +2945,38 @@ export class TrainSchedulingService { return direction === 'EXPORT'; } + /** + * Every export booking being confirmed loaded must already be received at the + * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this + * is the check that the cargo is physically in the yard before we call it loaded. + */ + private async assertExportBookingsReceived(bookingIds: string[]): Promise { + if (!bookingIds.length) return; + const rows: Array<{ reference: string | null }> = await this.dataSource.query( + `SELECT b.reference + FROM freight.bookings b + WHERE b.id = ANY($1) + AND b.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id + AND inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED') + AND COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) IS NOT NULL + )`, + [bookingIds], + ); + if (rows.length) { + const refs = rows.map((r) => r.reference ?? '(unknown)').join(', '); + throw new BadRequestException( + `These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`, + ); + } + } + private buildImportLoadListHtml(loadList: Awaited>): string { const esc = (value: unknown) => String(value ?? '-') @@ -3815,25 +3883,24 @@ export class TrainSchedulingService { ); } + // Corridor-aware: a booking belongs on this train when its origin and + // destination lie on the schedule's stop list in order — sub-corridor + // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The + // stop list is also what makes the wagon plan leg-aware below. + let stops = [dto.originStationId, dto.destinationStationId]; + if (targetScheduleId) { + const target = await this.trainSchedulesRepository.findById(targetScheduleId); + if (target) stops = await this.stopYardsForSchedule(target); + } if ( - await (async () => { - // Corridor-aware: a booking belongs on this train when its origin and - // destination lie on the schedule's stop list in order — sub-corridor - // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. - let stops = [dto.originStationId, dto.destinationStationId]; - if (targetScheduleId) { - const target = await this.trainSchedulesRepository.findById(targetScheduleId); - if (target) stops = await this.stopYardsForSchedule(target); + bookings.some((b) => { + if (targetScheduleId && b.trainScheduleId === targetScheduleId) { + return false; } - return bookings.some((b) => { - if (targetScheduleId && b.trainScheduleId === targetScheduleId) { - return false; - } - const fromIdx = stops.indexOf(b.originYardId); - const toIdx = stops.indexOf(b.destinationYardId); - return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx; - }); - })() + const fromIdx = stops.indexOf(b.originYardId); + const toIdx = stops.indexOf(b.destinationYardId); + return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx; + }) ) { violations.push('Selected bookings must lie on the schedule route (origin before destination)'); } @@ -3919,7 +3986,22 @@ export class TrainSchedulingService { stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; } - const planned = planWagonsWithStock({ bookings, allowed, stock }); + // Leg-aware stock: each booking consumes wagons only on the edges it rides, + // so a ride-along on an empty leg never competes with cargo on a full one. + const legByBookingId = new Map( + bookings.flatMap((b) => { + const from = stops.indexOf(b.originYardId); + const to = stops.indexOf(b.destinationYardId); + return from >= 0 && to > from ? [[b.id, { from, to }] as const] : []; + }), + ); + const planned = planWagonsWithStock({ + bookings, + allowed, + stock, + legs: legByBookingId, + edgeCount: Math.max(1, stops.length - 1), + }); violations.push(...planned.configIssues); const fittingBookings = planned.fitting; const deferredBookings: DeferredBookingRow[] = planned.deferred; @@ -3981,10 +4063,11 @@ export class TrainSchedulingService { ).values(), ]; pushLimit( - validateMixedTrainLimits( + validateMixedTrainLimitsPerEdge( wagonPlan, plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], trainLimits, + stops, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -6773,12 +6856,33 @@ export class TrainSchedulingService { status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, + // Which leg of the corridor this booking rides — the workspace can't + // tell a ride-along (intercity) or sub-corridor booking from through + // cargo without it. + tradeDirection: sb.booking?.tradeDirection ?? null, + originYardId: sb.booking?.originYardId ?? null, + destinationYardId: sb.booking?.destinationYardId ?? null, + origin: + sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null, + destination: + sb.booking?.destinationYard?.label ?? + sb.booking?.destinationYard?.code ?? + null, + wagonsRequired: + sb.booking?.wagonsRequired != null + ? Number(sb.booking.wagonsRequired) + : null, + loadedAt: sb.booking?.loadedAt?.toISOString() ?? null, + arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null, // Loaded/unloaded is tracked on the schedule↔booking link, not the // booking itself — staff flip it per booking in the workspace before // dispatch. Defaults UNLOADED for links written before the column. loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), })) ?? [], + // Ordered corridor stops (route milestones; falls back to the two + // endpoints) — lets the UI draw per-segment occupancy and label legs. + stops: this.mapScheduleStops(schedule), // True when the wagon plan above is served from the frozen snapshot (schedule // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge // it "historical" and skip re-pin affordances. @@ -6787,6 +6891,42 @@ export class TrainSchedulingService { }; } + /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ + private mapScheduleStops( + schedule: TrainSchedule, + ): Array<{ yardId: string; label: string }> { + const milestones = [...(schedule.route?.milestones ?? [])].sort( + (a, b) => a.sequenceNo - b.sequenceNo, + ); + const raw = milestones.length >= 2 + ? milestones.map((m) => ({ + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? m.yardId, + })) + : [ + { + yardId: schedule.originStationId, + label: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + schedule.originStationId, + }, + { + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + schedule.destinationStationId, + }, + ]; + const seen = new Set(); + return raw.filter((stop) => { + if (!stop.yardId || seen.has(stop.yardId)) return false; + seen.add(stop.yardId); + return true; + }); + } + private isHoldActive(booking: Booking): boolean { if (!booking.holdExpiresAt) return false; return booking.holdExpiresAt.getTime() > Date.now(); @@ -7195,6 +7335,171 @@ export class TrainSchedulingService { return { id: itemId, containerNumber: dto.containerNumber ?? null }; } + /** + * Staff rearrange: relocate a wagon's ENTIRE load (all its allocations — + * a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train. + * Whole-load moves keep every packing rule intact by construction (a valid + * load stays valid on any wagon whose type supports it), which is what lets + * a 20ft pair travel together and swap places with a 40ft, and lets bulk + * swap with containers. + * + * Three shapes, picked from the target: + * - target is an empty consist-only wagon (coupled on the built train, no + * slot row): REPIN — the source slot simply points at that physical wagon + * (type/capacity/length follow), and the wagon it left shows as empty. + * - target is an empty slot: allocations repoint to it and the load-coupled + * slot fields (assigned weight, status, board/alight leg) move across. + * - target is a loaded slot: the two loads swap wagons the same way. + * + * Validated per direction: the receiving wagon's type must support the + * incoming load type, and the incoming cargo must fit its rated payload. + */ + async moveWagonLoad( + scheduleId: string, + sourceWagonId: string, + dto: MoveWagonLoadDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) { + throw new BadRequestException('Cannot rearrange loads on a dispatched train'); + } + if (sourceWagonId === dto.targetWagonId) { + return this.getTrainScheduleById(scheduleId); + } + + const slots = schedule.trainSet?.wagons ?? []; + const source = slots.find((w) => w.id === sourceWagonId); + if (!source) { + throw new NotFoundException('Source wagon is not part of this schedule'); + } + + const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); + const loadAllocations = (trainSetWagonId: string) => + allocRepo.find({ where: { trainSetWagonId } }); + const sourceAllocs = await loadAllocations(source.id); + if (!sourceAllocs.length) { + throw new BadRequestException('Source wagon has no load to move'); + } + + // Target: a slot of this train set, or an empty consist-only wagon of the + // built train (physical wagon with no slot row yet). + const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null; + const consistWagon = targetSlot + ? null + : schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).findOne({ + where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + }) + : null; + if (!targetSlot && !consistWagon) { + throw new NotFoundException('Target wagon is not part of this schedule'); + } + const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; + + const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ + ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), + ]; + const cargoOf = (allocs: WagonBookingAllocation[]) => + allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); + const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) => + slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon'); + const checkReceives = ( + allocs: WagonBookingAllocation[], + label: string, + wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined, + capacityTons: number, + ) => { + const incoming = loadTypesOf(allocs); + // Unknown type or no declared support list → staff decides; don't block. + if (wagonType) { + const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase()); + for (const loadType of incoming) { + const ok = + supported.includes(loadType) || + (loadType === 'CONTAINER' && wagonType.supportsContainer) || + supported.length === 0; + if (!ok) { + throw new BadRequestException( + `Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`, + ); + } + } + } + const cargo = cargoOf(allocs); + if (capacityTons > 0 && cargo > capacityTons + 0.001) { + throw new BadRequestException( + `Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`, + ); + } + }; + + // What the target must be able to receive… + checkReceives( + sourceAllocs, + wagonLabel(targetSlot, consistWagon), + targetSlot ? targetSlot.wagonType : consistWagon?.wagonType, + Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)), + ); + // …and, on a swap, what comes back to the source. + if (targetAllocs.length) { + checkReceives( + targetAllocs, + `#${source.sequenceNo}`, + source.wagonType, + Number(source.capacityTons), + ); + } + + await this.dataSource.transaction(async (manager) => { + const slotRepo = manager.getRepository(TrainSetWagon); + const allocs = manager.getRepository(WagonBookingAllocation); + + // Empty consist wagon: repin the loaded slot onto that physical wagon. + // Allocations and load fields stay put; only the wagon identity changes. + if (consistWagon) { + await slotRepo.update(source.id, { + physicalWagonId: consistWagon.id, + wagonTypeId: consistWagon.wagonTypeId, + capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)), + lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)), + }); + return; + } + + const target = targetSlot as TrainSetWagon; + // Load-coupled slot fields travel with the load; wagon identity stays. + const loadFieldsOf = (slot: TrainSetWagon) => ({ + assignedWeightTons: slot.assignedWeightTons, + status: slot.status, + boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, + }); + const emptyLoadFields = { + assignedWeightTons: 0, + status: 'PLANNED', + boardYardId: null, + alightYardId: null, + }; + const sourceLoadFields = loadFieldsOf(source); + const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields; + + for (const alloc of sourceAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: target.id }); + } + for (const alloc of targetAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: source.id }); + } + await slotRepo.update(target.id, sourceLoadFields); + await slotRepo.update(source.id, targetLoadFields); + }); + + return this.getTrainScheduleById(scheduleId); + } + async getUnassignedBookings(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 5870d3785..778dc70dd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => { expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']); }); }); + +describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => { + const allowed = { + byContainerTypeId: new Map([['ct-1', [nw6]]]), + byCargoTypeId: new Map(), + }; + // Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1. + const legs = (entries: Array<[string, { from: number; to: number }]>) => + new Map(entries); + + it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => { + // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only. + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + ['EXPORT-1', { from: 1, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.fitting.map((b) => b.id).sort()).toEqual([ + 'EXPORT-1', + 'INTERCITY-1', + ]); + // Two slots planned, but both drawn from the single physical wagon. + expect(result.plan).toHaveLength(2); + }); + + it('still defers when the legs overlap and stock is exhausted', () => { + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + // Both ride edge 0 — they compete for the one wagon. + ['EXPORT-1', { from: 0, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']); + expect(result.deferred).toHaveLength(1); + expect(result.deferred[0]!.reference).toBe('INTERCITY-1'); + expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left'); + }); + + it('never packs bookings with different legs into the same wagon slot', () => { + // Two 20ft units with room to share one wagon by TEU — but disjoint legs + // must open separate slots (each with its own leg), not one mixed slot. + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 2]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + ['EXPORT-1', { from: 1, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.plan).toHaveLength(2); + const bookingsPerSlot = result.plan.map((s) => + [...new Set(s.allocations.map((a) => a.bookingId))].sort(), + ); + expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]); + }); + + it('behaves exactly like the whole-route planner when no legs are given', () => { + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + }); + + // One wagon, two 20ft bookings: they TEU-share the single slot (legacy). + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 6a3c1c49f..699d7a432 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -58,8 +58,18 @@ type OpenSlot = { /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; + /** + * Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only + * share a slot when their legs are identical — mixing corridors in one slot + * would degrade it to a whole-route slot (see stampSlotLegs) and silently + * re-occupy edges the cargo never rides. + */ + legKey: string; }; +/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */ +export type BookingLeg = { from: number; to: number }; + type PlacementProblem = { kind: 'config' | 'stock'; message: string; @@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS const shortageFor = ( booking: Booking, candidates: WagonType[], - remaining: Map, + availableOf: (wagonTypeId: string) => number, ): BookingWagonShortage => { const wagonsNeeded = booking.freightType === 'BULK' @@ -100,7 +110,7 @@ const shortageFor = ( ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); const wagonsAvailable = candidates.reduce( - (sum, wt) => sum + (remaining.get(wt.id) ?? 0), + (sum, wt) => sum + availableOf(wt.id), 0, ); return { @@ -140,14 +150,53 @@ export function planWagonsWithStock(params: { bookings: Booking[]; allowed: AllowedWagonTypeMap; stock: WagonStock; + /** + * Leg-aware stock: booking id → the stop-index range it rides. When given + * (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so + * the same physical wagon can serve an intercity booking on Gelan→Adama and + * an export booking on Adama→Doraleh — disjoint legs never compete for + * stock. Omitted → one edge, byte-identical to the old whole-route behavior. + */ + legs?: Map; + edgeCount?: number; }): FlexPlanResult { - const { bookings, allowed, stock } = params; - const remaining = new Map(stock.remainingByTypeId); + const { bookings, allowed, stock, legs } = params; + const edgeCount = Math.max(1, params.edgeCount ?? 1); const openSlots: OpenSlot[] = []; const fitting: Booking[] = []; const deferred: DeferredBookingRow[] = []; const configIssues = new Set(); + const legFor = (booking: Booking): BookingLeg => { + const leg = legs?.get(booking.id); + if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { + return { from: 0, to: edgeCount }; + } + return leg; + }; + const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`; + + // Wagons of a type in use per corridor edge. A type is available for a leg + // when its busiest edge WITHIN that leg still has stock spare — the max over + // edges is the number of physical wagons the type needs simultaneously. + const usedPerEdge = new Map(); + const usedRow = (wagonTypeId: string): number[] => { + let row = usedPerEdge.get(wagonTypeId); + if (!row) { + row = new Array(edgeCount).fill(0); + usedPerEdge.set(wagonTypeId, row); + } + return row; + }; + const availableFor = (wagonTypeId: string, leg: BookingLeg): number => { + const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0; + const row = usedPerEdge.get(wagonTypeId); + if (!row) return total; + let busiest = 0; + for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0); + return total - busiest; + }; + const noStockMessage = (candidates: WagonType[]): string => { const codes = candidates.map((wt) => wt.code).join('/'); return stock.mode === 'TRAIN' @@ -155,13 +204,14 @@ export function planWagonsWithStock(params: { : `No available ${codes} wagon at the yard`; }; - /** Open a new wagon of one of the candidate types, consuming stock. */ + /** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */ const openSlot = ( candidates: WagonType[], kind: SlotLoadType, cargoTypeId: string | null, + leg: BookingLeg, ): OpenSlot | PlacementProblem => { - const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); + const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); if (!inStock.length) { return { kind: 'stock', message: noStockMessage(candidates), candidates }; } @@ -170,22 +220,26 @@ export function planWagonsWithStock(params: { const chosen = [...inStock].sort((a, b) => kind === 'BULK' ? Number(b.capacityTons) - Number(a.capacityTons) || - (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0) - : (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0), + availableFor(b.id, leg) - availableFor(a.id, leg) + : availableFor(b.id, leg) - availableFor(a.id, leg), )[0]; - remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1); + const row = usedRow(chosen.id); + for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1; const open: OpenSlot = { slot: slotFromWagonType(chosen, kind), teuUsed: 0, kind, cargoTypeId, freeCapacityTons: Number(chosen.capacityTons), + legKey: legKeyOf(leg), }; openSlots.push(open); return open; }; const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { + const leg = legFor(booking); + const legKey = legKeyOf(leg); if (booking.freightType === 'CONTAINER') { const units = expandBookingContainerUnits([booking]); if (!units.length) { @@ -209,11 +263,12 @@ export function planWagonsWithStock(params: { let target = openSlots.find( (open) => open.kind === 'CONTAINER' && + open.legKey === legKey && allowedIds.has(open.slot.wagonTypeId) && open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, ); if (!target) { - const openedSlot = openSlot(candidates, 'CONTAINER', null); + const openedSlot = openSlot(candidates, 'CONTAINER', null, leg); if ('message' in openedSlot) return openedSlot; target = openedSlot; } @@ -246,6 +301,7 @@ export function planWagonsWithStock(params: { for (const open of openSlots) { if (remainingWeight <= 0) break; if (open.kind !== 'BULK') continue; + if (open.legKey !== legKey) continue; if (open.cargoTypeId !== cargoTypeId) continue; if (!allowedIds.has(open.slot.wagonTypeId)) continue; if (open.freeCapacityTons <= 0) continue; @@ -263,7 +319,7 @@ export function planWagonsWithStock(params: { } while (remainingWeight > 0 || !placedAnywhere) { - const openedSlot = openSlot(candidates, 'BULK', cargoTypeId); + const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg); if ('message' in openedSlot) return openedSlot; const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); addAllocation( @@ -282,7 +338,9 @@ export function planWagonsWithStock(params: { for (const booking of sortBookingsForScheduling(bookings)) { // Snapshot so a booking that doesn't fully fit leaves no half-placed wagons. - const remainingSnapshot = new Map(remaining); + const usedSnapshot = new Map( + [...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]), + ); const slotCountSnapshot = openSlots.length; const slotStateSnapshot = openSlots.map((open) => ({ teuUsed: open.teuUsed, @@ -299,8 +357,8 @@ export function planWagonsWithStock(params: { } // Roll back this booking's partial placements. - remaining.clear(); - for (const [key, value] of remainingSnapshot) remaining.set(key, value); + usedPerEdge.clear(); + for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value); openSlots.length = slotCountSnapshot; openSlots.forEach((open, index) => { const snap = slotStateSnapshot[index]; @@ -315,11 +373,14 @@ export function planWagonsWithStock(params: { }); if (problem.kind === 'config') configIssues.add(problem.message); - // remaining is rolled back here, so the shortage counts the stock this + // Usage is rolled back here, so the shortage counts the stock this // booking actually saw — not what its own partial placement consumed. + const bookingLeg = legFor(booking); const shortage = problem.kind === 'stock' && problem.candidates?.length - ? shortageFor(booking, problem.candidates, remaining) + ? shortageFor(booking, problem.candidates, (wagonTypeId) => + Math.max(0, availableFor(wagonTypeId, bookingLeg)), + ) : null; deferred.push({ id: booking.id, 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 bb5ce890c..1aa555531 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 @@ -525,6 +525,40 @@ export function validateMixedTrainLimits( ); } +/** + * Leg-aware limit check: with a real stop list, a slot only counts on the + * edges it actually rides (boardYardId→alightYardId; null = the schedule's + * own endpoint). Each edge is validated as its own consist, so an intercity + * wagon on Gelan→Adama never counts against a train that is full only on + * Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check. + */ +export function validateMixedTrainLimitsPerEdge( + wagonPlan: WagonPlanSlot[], + wagonTypes: Array>, + limits: TrainLimitConfig | undefined, + stops: string[], +): string[] { + if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); + const lastIdx = stops.length - 1; + const spans = wagonPlan.map((slot) => { + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx; + // A yard missing from the stop list keeps the slot on the whole route. + return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx }; + }); + const violations = new Set(); + for (let edge = 0; edge < lastIdx; edge += 1) { + const active = wagonPlan.filter( + (_, i) => spans[i].from <= edge && edge < spans[i].to, + ); + if (!active.length) continue; + for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { + violations.add(violation); + } + } + return [...violations]; +} + export function validate20ftContainerRules( units: ContainerUnitRow[], placements: ContainerPlacementInput[], diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 3658f4f37..662b83534 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -29,6 +29,9 @@ import { const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; +/** Locomotive statuses that block a train from reactivating. */ +const UNFIT_FOR_REACTIVATION = new Set(['MAINTENANCE', 'OUT_OF_SERVICE', 'UNAVAILABLE']); + /** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */ export interface ActiveScheduleRef { id: string; @@ -596,11 +599,30 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */ + /** + * Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled + * again. Blocked if any coupled locomotive is unfit for service — a + * deactivated train can sit parked for a while and its locomotives may have + * since been sent to maintenance independently; reactivating must not wave + * a down locomotive back onto the schedule board. + */ async activate(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ where: { id } }); if (!train) throw new NotFoundException(`Train ${id} not found`); if (train.status === Freight.TrainStatus.Deactivated) { + const links = await this.dataSource + .getRepository(TrainLocomotive) + .find({ where: { trainId: id }, relations: { locomotive: true } }); + const unfit = links + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)) + .filter((loco) => UNFIT_FOR_REACTIVATION.has(loco.status)); + if (unfit.length) { + const names = unfit.map((l) => `${l.code} (${l.status})`).join(', '); + throw new ConflictException( + `Train cannot be reactivated: ${names} ${unfit.length > 1 ? 'are' : 'is'} not fit for service. Detach and replace before reactivating.`, + ); + } await this.dataSource .getRepository(Train) .update(id, { status: Freight.TrainStatus.Available }); diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts new file mode 100644 index 000000000..5696f00cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.spec.ts @@ -0,0 +1,61 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { CreateVehicleDto } from './create-vehicle.dto'; + +const base = { + vehicleType: 'TRUCK', + manufacturer: 'IVECO', + model: 'HYT', + year: 2020, + fuelType: 'DIESEL', + capacity: 0, + status: 'ACTIVE', +}; + +const errorsFor = (over: Record) => + validate(plainToInstance(CreateVehicleDto, { ...base, ...over })); + +const plateErrors = ( + errors: Awaited>, + property: string, +) => errors.find((e) => e.property === property && e.constraints?.matches); + +describe('CreateVehicleDto — plate format', () => { + it('accepts a plate like ET-9875', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'ET-9875' }), 'plateNumber')).toBeUndefined(); + }); + + it('accepts a plate like AA-8642', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'AA-8642' }), 'plateNumber')).toBeUndefined(); + }); + + it('upper-cases a lower-case plate before validating', async () => { + const dto = plainToInstance(CreateVehicleDto, { ...base, plateNumber: 'et-9875' }); + expect(dto.plateNumber).toBe('ET-9875'); + expect(plateErrors(await validate(dto), 'plateNumber')).toBeUndefined(); + }); + + it('rejects a free-text plate like assadasd', async () => { + expect(plateErrors(await errorsFor({ plateNumber: 'assadasd' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a plate with no letters or no digits', async () => { + expect(plateErrors(await errorsFor({ plateNumber: '1234' }), 'plateNumber')).toBeDefined(); + expect(plateErrors(await errorsFor({ plateNumber: 'ABCD' }), 'plateNumber')).toBeDefined(); + }); + + it('rejects a bad trailer plate but allows a valid one', async () => { + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'asdasdasda' }), 'trailerPlateNo'), + ).toBeDefined(); + expect( + plateErrors(await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: 'AA-8642' }), 'trailerPlateNo'), + ).toBeUndefined(); + }); + + it('allows an empty trailer plate (optional)', async () => { + const errors = await errorsFor({ plateNumber: 'ET-1', trailerPlateNo: '' }); + expect(plateErrors(errors, 'trailerPlateNo')).toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts index 33d441ecb..4dda3c053 100644 --- a/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts +++ b/apps/edr-freight-api/src/modules/vehicles/dto/create-vehicle.dto.ts @@ -1,7 +1,30 @@ -import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { IsString, IsEnum, IsNumber, IsOptional, IsUUID, Matches } from 'class-validator'; +import { Transform } from 'class-transformer'; import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity'; +/** + * A vehicle plate is two or three letters, a hyphen, then two to six digits — + * e.g. ET-9875 or AA-8642. Kept in one place so plate, power-plate and trailer + * all match and the message stays consistent. + */ +export const VEHICLE_PLATE_REGEX = /^[A-Z]{2,3}-\d{2,6}$/; +export const VEHICLE_PLATE_MESSAGE = + 'must be letters and numbers like ET-9875 or AA-8642'; + +/** + * Trim and upper-case a plate before validating, so "et-9875" is accepted. An + * empty optional plate (trailer/power) becomes undefined so @IsOptional skips it + * rather than failing the pattern. + */ +const normalizePlate = ({ value }: { value: unknown }) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim().toUpperCase(); + return trimmed === '' ? undefined : trimmed; +}; + export class CreateVehicleDto { + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() plateNumber!: string; @@ -47,10 +70,14 @@ export class CreateVehicleDto { code?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Power plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() powerPlateNo?: string; @IsOptional() + @Transform(normalizePlate) + @Matches(VEHICLE_PLATE_REGEX, { message: `Trailer plate number ${VEHICLE_PLATE_MESSAGE}` }) @IsString() trailerPlateNo?: string; diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts new file mode 100644 index 000000000..cb810abaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts @@ -0,0 +1,44 @@ +import { ConflictException } from '@nestjs/common'; + +import { VehiclesService } from './vehicles.service'; + +// One driver ⇒ one truck: create/update must refuse a driver already assigned +// to another (non-deleted) vehicle until they are detached. +describe('VehiclesService driver assignment guard', () => { + const otherTruck = { id: 'v2', plateNumber: '3-11111', assignedDriverId: 'd1' }; + + const makeService = (findOne: jest.Mock) => + new VehiclesService( + { findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any, + { record: jest.fn() } as any, + ); + + it('rejects create when the driver is on another truck', async () => { + // First findOne = plate uniqueness (null), second = driver holder. + const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck); + const svc = makeService(findOne); + await expect( + svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any), + ).rejects.toThrow(ConflictException); + }); + + it('rejects update when reassigning a driver still attached elsewhere', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: null }) // findById + .mockResolvedValueOnce(otherTruck); // driver holder + const svc = makeService(findOne); + await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).rejects.toThrow( + ConflictException, + ); + }); + + it('allows update that keeps the same driver on the same truck', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: 'd1' }); + const svc = makeService(findOne); + await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).resolves.toBeDefined(); + expect(findOne).toHaveBeenCalledTimes(1); // guard skipped — no holder lookup + }); +}); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 25260e86f..98d38cbce 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -20,6 +20,25 @@ export class VehiclesService { private readonly history: FleetHistoryService, ) {} + /** + * A driver holds one truck at a time — reassignment requires detaching them + * from their current truck first. + * ponytail: app-level guard only (race window); add a partial unique index on + * assigned_driver_id if concurrent fleet edits ever become real. + */ + private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise { + const holder = await this.vehicleRepo.findOne({ + where: exceptVehicleId + ? { assignedDriverId: driverId, id: Not(exceptVehicleId) } + : { assignedDriverId: driverId }, + }); + if (holder) { + throw new ConflictException( + `This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`, + ); + } + } + async create(dto: CreateVehicleDto): Promise { const existing = await this.vehicleRepo.findOne({ where: { plateNumber: dto.plateNumber }, @@ -31,6 +50,10 @@ export class VehiclesService { ); } + if (dto.assignedDriverId) { + await this.assertDriverUnassigned(dto.assignedDriverId); + } + const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, @@ -121,6 +144,10 @@ export class VehiclesService { } } + if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) { + await this.assertDriverUnassigned(dto.assignedDriverId, id); + } + const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 49fb22fde..f86261d3c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -1,5 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { WAREHOUSE_INVENTORY_STATUSES, @@ -71,4 +72,27 @@ export class FilterWarehouseInventoryDto { @IsOptional() @IsString() dateTo?: string; + + // ── KPI drill-down filters ────────────────────────────────────────────── + // Each mirrors one opsStats() counter so a dashboard card's count always + // equals the length of the list it opens. + + @ApiPropertyOptional({ type: Boolean, description: 'Only items received (created) today' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + receivedToday?: boolean; + + @ApiPropertyOptional({ type: Boolean, description: 'Only RECEIVED items with no inspection yet' }) + @IsOptional() + @Transform(({ value }) => (value == null ? undefined : value === true || value === 'true' || value === '1')) + @IsBoolean() + pendingInspection?: boolean; + + @ApiPropertyOptional({ type: Number, minimum: 1, description: 'Only in-warehouse items older than N days' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + @IsInt() + @Min(1) + agingOverDays?: number; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index a0a7ad70f..c90fb5a16 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -8,8 +8,9 @@ export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number]; * One import handover. A booking has a single handover when one truck takes the * whole booking (`truckAssignmentId` null = per-booking), or one per truck when * multiple trucks are used. Self-haul handovers are generated on truck arrival - * and signed before the truck leaves; EDR last-mile handovers are generated at - * delivery (after exit). + * and signed before the truck leaves; EDR last-mile handovers are generated + * when the EDR truck exits the warehouse (with its exit paper) and signed by + * the customer in the portal on delivery — one signature per truck. */ @Entity({ schema: 'freight', name: 'booking_handovers' }) @Index(['bookingId']) @@ -21,6 +22,10 @@ export class BookingHandover extends BaseEntity { @Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true }) truckAssignmentId?: string | null; + /** EDR last-mile vehicle assignment this handover belongs to; null = per-booking. */ + @Column({ name: 'edr_assignment_id', type: 'uuid', nullable: true }) + edrAssignmentId?: string | null; + /** Denormalised plate for display / EDR trucks (which aren't customer trucks). */ @Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true }) truckPlate?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts new file mode 100644 index 000000000..f89d987ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/exit-inspection-blocks.spec.ts @@ -0,0 +1,66 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +// Exercises the per-truck [Exit Inspection] block helpers directly (no DI). +const svc = Object.create(WarehouseInventoryService.prototype) as any; + +const arrivalA = + '[Exit Inspection]\nTruck Plate: 3-15288/56858\nDriver: Abebe Lemeno\nGate In Time: 2026-07-21T08:00:00.000Z\nTare Weight: 12 t'; +const arrivalB = + '[Exit Inspection]\nTruck Plate: 3-85957/48562\nDriver: Suleman Tamrat\nGate In Time: 2026-07-21T09:00:00.000Z\nWeighing: SKIPPED'; + +describe('per-truck exit inspection blocks', () => { + it('keeps truck A intact when truck B arrives', () => { + const afterA = svc.replaceExitInspectionNote('Receive note', arrivalA, '3-15288/56858'); + const afterB = svc.replaceExitInspectionNote(afterA, arrivalB, '3-85957/48562'); + expect(afterB).toContain('Abebe Lemeno'); + expect(afterB).toContain('Suleman Tamrat'); + expect(afterB.match(/\[Exit Inspection\]/g)).toHaveLength(2); + expect(afterB.startsWith('Receive note')).toBe(true); + }); + + it("exit for truck A updates only A's block and preserves arrival data", () => { + const notes = svc.replaceExitInspectionNote( + svc.replaceExitInspectionNote(null, arrivalA, '3-15288/56858'), + arrivalB, + '3-85957/48562', + ); + const dto = svc.preserveTruckArrivalForExit( + { truckPlateNumber: '3-15288/56858', grossWeight: 40, gateOutTime: '2026-07-21T12:00:00.000Z' }, + notes, + ); + expect(dto.driverName).toBe('Abebe Lemeno'); + expect(dto.tareWeight).toBe(12); + expect(dto.weighingSkipped).toBeUndefined(); + const exitNote = svc.buildExitInspectionNote(dto); + const replaced = svc.replaceExitInspectionNote(notes, exitNote, dto.truckPlateNumber); + expect(replaced).toContain('Gross Weight: 40 t'); + expect(replaced).toContain('Net Weight: 28 t'); + expect(replaced).toContain('Suleman Tamrat'); // B untouched + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(2); + }); + + it('skipped weighing records the container-derived net in the note', () => { + const dto = { + truckPlateNumber: '3-85957/48562', + driverName: 'Suleman Tamrat', + weighingSkipped: true, + netWeight: 27.5, + gateInTime: '2026-07-21T09:00:00.000Z', + gateOutTime: '2026-07-21T13:00:00.000Z', + }; + const note = svc.buildExitInspectionNote(dto); + expect(note).toContain('Weighing: SKIPPED'); + expect(note).toContain('Net Weight: 27.5 t'); + }); + + it('matches a legacy comma-joined plate list and keeps foreign notes', () => { + const legacy = + 'Receive note\n\n[Exit Inspection]\nTruck Plate: 3-15288/56858, 3-85957/48562\nDriver: Abebe Lemeno\nTare Weight: 12 t\nCUSTOMER_DELIVERY_APPROVAL:{"ok":true}'; + const block = svc.extractExitInspectionForPlate(legacy, '3-15288/56858'); + expect(block).toContain('Abebe Lemeno'); + const replaced = svc.replaceExitInspectionNote(legacy, arrivalA, '3-15288/56858'); + expect(replaced.match(/\[Exit Inspection\]/g)).toHaveLength(1); + expect(replaced).toContain('CUSTOMER_DELIVERY_APPROVAL:{"ok":true}'); + expect(replaced).toContain('Receive note'); + }); +}); 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 d50b27150..81f83a57a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -1,9 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; -import { DataSource, EntityManager, IsNull } from 'typeorm'; +import { DataSource, EntityManager, IsNull, Repository } from 'typeorm'; -import { BookingHandover } from './entities/booking-handover.entity'; +import { BookingHandover, HandoverMileType } from './entities/booking-handover.entity'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; @@ -12,7 +12,10 @@ import { sendCompanyChannels } from '../notifications/notify-company.util'; * Import handover records. A booking has one handover per truck (single truck ⇒ * one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type: * - SELF_HAUL: generated when the customer truck arrives, signed before it leaves. - * - EDR_LAST_MILE: generated at delivery (after exit). + * - EDR_LAST_MILE: generated when the EDR truck exits the warehouse (with its + * exit paper), signed by the customer in the portal per truck; once every + * handover is signed the delivery auto-completes (inventory / cargo / + * booking → delivered). */ @Injectable() export class HandoverService { @@ -25,14 +28,22 @@ export class HandoverService { ) {} /** Tell the customer a handover is ready and needs their signature. */ - private async notifySignNeeded(bookingId: string, reference: string): Promise { + private async notifySignNeeded( + bookingId: string, + reference: string, + opts: { mileType?: HandoverMileType; truckPlate?: string | null } = {}, + ): Promise { try { const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, [bookingId], ); if (!b?.companyId) return; - const body = `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; + const truck = opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''; + const body = + opts.mileType === 'EDR_LAST_MILE' + ? `Your goods for booking ${b.reference} are on their way${truck}. Please review and sign handover ${reference} from the portal to confirm receipt of the delivery.` + : `Your import handover ${reference} for booking ${b.reference} is ready. Please review and sign it from the portal before the truck leaves.`; await this.inbox.notify({ recipients: { companyId: b.companyId }, audience: NotificationAudience.PORTAL, @@ -117,31 +128,98 @@ export class HandoverService { return saved; } - /** - * EDR last-mile: generate a handover at delivery (after exit). One per EDR - * truck (by plate) or per booking. Idempotent by (booking, plate). - */ - async ensureAtDelivery( + /** Find an existing EDR handover by assignment, else by plate, else booking-level. */ + private async findEdrHandover( + repo: Repository, bookingId: string, - opts: { truckPlate?: string | null; truckAssignmentId?: string | null }, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + ): Promise { + if (opts.edrAssignmentId) { + const byAssignment = await repo.findOne({ + where: { bookingId, edrAssignmentId: opts.edrAssignmentId }, + }); + if (byAssignment) return byAssignment; + } + if (opts.truckPlate) { + return repo.findOne({ + where: { bookingId, mileType: 'EDR_LAST_MILE', truckPlate: opts.truckPlate }, + }); + } + return repo.findOne({ + where: { + bookingId, + mileType: 'EDR_LAST_MILE', + truckPlate: IsNull(), + edrAssignmentId: IsNull(), + }, + }); + } + + /** + * EDR last-mile: generate the handover when the EDR truck exits the warehouse + * (alongside its exit paper) and ask the customer to sign it from the portal. + * One per truck (multiple trucks ⇒ one each) or booking-level when the truck + * cannot be resolved. Idempotent by (booking, assignment) / (booking, plate). + */ + async ensureForDepartedEdrTruck( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, manager?: EntityManager, ): Promise { const m = manager ?? this.dataSource.manager; const repo = m.getRepository(BookingHandover); - const existing = await repo.findOne({ - where: { - bookingId, - truckPlate: opts.truckPlate ?? IsNull(), - truckAssignmentId: opts.truckAssignmentId ?? IsNull(), - }, - }); + const existing = await this.findEdrHandover(repo, bookingId, opts); if (existing) return existing; const reference = await this.generateReference(bookingId, m); - return repo.save( + const saved = await repo.save( repo.create({ bookingId, - truckAssignmentId: opts.truckAssignmentId ?? null, + edrAssignmentId: opts.edrAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'EDR_LAST_MILE', + reference, + generatedAt: new Date(), + }), + ); + this.logger.log( + `EDR handover ${reference} generated on truck exit for booking ${bookingId}` + + (opts.truckPlate ? ` (truck ${opts.truckPlate})` : ''), + ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** + * EDR last-mile: ensure a handover exists at delivery and stamp delivered_at. + * Normally the handover was already generated on truck exit — this only fills + * the delivery timestamp; a handover is created here only for legacy flows + * where the exit was recorded before this feature existed. + */ + async ensureAtDelivery( + bookingId: string, + opts: { truckPlate?: string | null; edrAssignmentId?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await this.findEdrHandover(repo, bookingId, opts); + if (existing) { + if (!existing.deliveredAt) { + existing.deliveredAt = new Date(); + await repo.save(existing); + } + return existing; + } + + const reference = await this.generateReference(bookingId, m); + const saved = await repo.save( + repo.create({ + bookingId, + edrAssignmentId: opts.edrAssignmentId ?? null, truckPlate: opts.truckPlate ?? null, mileType: 'EDR_LAST_MILE', reference, @@ -149,6 +227,25 @@ export class HandoverService { deliveredAt: new Date(), }), ); + void this.notifySignNeeded(bookingId, reference, { + mileType: 'EDR_LAST_MILE', + truckPlate: opts.truckPlate, + }); + return saved; + } + + /** Re-send the sign notification for every unsigned handover on the booking. */ + async notifyUnsignedForBooking(bookingId: string): Promise { + const unsigned = await this.dataSource.getRepository(BookingHandover).find({ + where: { bookingId, signedAt: IsNull() }, + order: { generatedAt: 'ASC' }, + }); + for (const h of unsigned) { + await this.notifySignNeeded(bookingId, h.reference, { + mileType: h.mileType, + truckPlate: h.truckPlate, + }); + } } /** @@ -182,6 +279,27 @@ export class HandoverService { } } + /** + * Sign one handover (EDR last-mile: the customer signs per truck). Returns the + * fresh handover; idempotent — an already-signed handover is returned as-is. + */ + async sign( + handoverId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { + const repo = this.dataSource.getRepository(BookingHandover); + const handover = await repo.findOne({ where: { id: handoverId } }); + if (!handover) { + throw new NotFoundException(`Handover ${handoverId} not found`); + } + if (handover.signedAt) return handover; + handover.signedAt = new Date(); + handover.signedByUserId = userId ?? null; + handover.signerName = signerName?.trim() || null; + return repo.save(handover); + } + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ async signForBooking( bookingId: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts new file mode 100644 index 000000000..38268af11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/receive-export-paid.spec.ts @@ -0,0 +1,55 @@ +import { BadRequestException } from '@nestjs/common'; + +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Export cargo is received into the warehouse to wait for its train, and only a + * paid booking may be received — otherwise storage and a GRN would start against + * cargo the customer has not settled. Import is never blocked: it arrives OFF a + * train and its receive is the unload. + * + * The guard touches only the DataSource, so the instance is built off the + * prototype rather than stubbing all 20-odd collaborators. + */ +type Guard = ( + bookingId: string | null | undefined, + direction: string | null, +) => Promise; + +function makeGuard(paymentStatus: string | null) { + const query = jest.fn().mockResolvedValue([{ paymentStatus }]); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + const guard = ( + service as unknown as { assertExportBookingPaid: Guard } + ).assertExportBookingPaid.bind(service); + return { guard, query }; +} + +describe('receive() — export paid gate', () => { + it('rejects an unpaid export booking', async () => { + const { guard } = makeGuard('PENDING'); + + await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows a paid export booking', async () => { + const { guard } = makeGuard('PAID'); + + await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined(); + }); + + it('never blocks import, paid or not', async () => { + const { guard, query } = makeGuard('PENDING'); + + await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + }); + + it('ignores a receive with no booking attached', async () => { + const { guard, query } = makeGuard('PENDING'); + + await expect(guard(null, 'EXPORT')).resolves.toBeUndefined(); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 2373cb6d8..0174ef3e9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -486,6 +486,21 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('handovers/:handoverId/sign') + @ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' }) + signHandover( + @Param('handoverId', ParseUUIDPipe) handoverId: string, + @Body() dto: ApproveDeliveryDto, + @Request() req: { user?: { id?: string; sub?: string } }, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.signHandover( + handoverId, + user?.id ?? req.user?.id ?? req.user?.sub, + dto.signerName, + ); + } + @Post('bookings/:bookingId/request-handover-signature') @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { @@ -513,9 +528,16 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handover-document') - @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) - async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { - const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' }) + async bookingHandoverDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Res() res: Response, + @Query('handoverId') handoverId?: string, + ) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking( + bookingId, + handoverId || undefined, + ); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename="${filename}"`); res.setHeader('Content-Length', buffer.length); @@ -534,6 +556,12 @@ export class WarehouseInventoryController { return this.inventoryService.bookingContainerWeights(bookingId); } + @Get('bookings/:bookingId/location') + @ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" }) + bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingLocation(bookingId); + } + @Post(':id/deliver') @BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) 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 d05007d8c..b5df563d9 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 @@ -1,6 +1,19 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + Between, + DataSource, + EntityManager, + FindManyOptions, + FindOptionsWhere, + ILike, + In, + IsNull, + LessThanOrEqual, + MoreThanOrEqual, + Raw, +} from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; @@ -13,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; +import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { @@ -68,6 +82,7 @@ const isLoadableWagonStatus = (status: string | null | undefined) => const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:'; const HANDOVER_DOCUMENT_MARKER = '[Handover Document]'; +const EXIT_INSPECTION_MARKER = '[Exit Inspection]'; export interface InventoryInquiryResult { id: string; @@ -396,6 +411,7 @@ export class WarehouseInventoryService { private readonly signatures: SignaturesService, private readonly handover: HandoverService, private readonly inbox: NotificationInboxService, + private readonly events: EventEmitter2, ) {} /** @@ -417,12 +433,16 @@ export class WarehouseInventoryService { * * Covers both haulage paths because the gate does: a customer's own truck and * an EDR last-mile truck arrive at the same barrier and need the same paper. - * "On site" means arrived and not yet departed. + * Includes trucks assigned but not yet arrived, flagged INBOUND, so staff see + * what is coming as well as what is here — an assigned truck only stamps + * `arrived_at` when it reaches the warehouse. A truck drops off the list once + * it departs. */ async trucksOnSite(): Promise< Array<{ source: 'CUSTOMER' | 'EDR'; assignmentId: string; + status: 'INBOUND' | 'ON_SITE'; plateNumber: string | null; driverName: string | null; truckType: string | null; @@ -436,6 +456,7 @@ export class WarehouseInventoryService { return this.dataSource.query( `SELECT 'CUSTOMER' AS "source", a.id AS "assignmentId", + CASE WHEN a.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status", a.plate_number AS "plateNumber", a.driver_name AS "driverName", a.truck_type AS "truckType", @@ -450,13 +471,13 @@ export class WarehouseInventoryService { JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies company ON company.id = b.company_id WHERE a.deleted_at IS NULL - AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL UNION ALL SELECT 'EDR' AS "source", va.id AS "assignmentId", + CASE WHEN va.arrived_at IS NULL THEN 'INBOUND' ELSE 'ON_SITE' END AS "status", COALESCE(v.plate_number, v.power_plate_no) AS "plateNumber", NULLIF(TRIM(CONCAT_WS(' ', d.first_name, d.last_name)), '') AS "driverName", v.vehicle_type AS "truckType", @@ -474,10 +495,11 @@ export class WarehouseInventoryService { LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id LEFT JOIN freight.companies company ON company.id = b.company_id WHERE va.deleted_at IS NULL - AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL - ORDER BY "arrivedAt" ASC`, + -- On-site trucks first, each group oldest-arrival first; inbound trucks + -- (null arrival) sort to the end. + ORDER BY "arrivedAt" ASC NULLS LAST`, ); } @@ -502,12 +524,20 @@ export class WarehouseInventoryService { WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", - (SELECT count(*)::int FROM freight.customer_truck_assignments - WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite", + -- Both haulage paths, mirroring the ON_SITE rows of trucksOnSite() + ((SELECT count(*)::int FROM freight.customer_truck_assignments a + JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL + WHERE a.deleted_at IS NULL AND a.arrived_at IS NOT NULL AND a.departed_at IS NULL) + + + (SELECT count(*)::int FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL + JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL + WHERE va.deleted_at IS NULL AND va.arrived_at IS NOT NULL AND va.departed_at IS NULL)) AS "trucksOnSite", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL - AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + AND status = ANY($1) AND created_at < now() - interval '7 days') AS "itemsAging"`, + [this.IN_WAREHOUSE_STATUSES], ); return { receivedToday: row?.receivedToday ?? 0, @@ -519,7 +549,7 @@ export class WarehouseInventoryService { } /** In-warehouse statuses used by the dwell / aging metrics. */ - private readonly IN_WAREHOUSE_STATUSES = [ + private readonly IN_WAREHOUSE_STATUSES: WarehouseInventoryStatus[] = [ 'RECEIVED', 'UNLOADED', 'STORED', @@ -947,7 +977,7 @@ export class WarehouseInventoryService { ? LessThanOrEqual(new Date(filter.dateTo)) : undefined; - const base = { + const base: FindOptionsWhere = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), ...(filter.zoneId ? { zoneId: filter.zoneId } : {}), @@ -961,6 +991,24 @@ export class WarehouseInventoryService { ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; + // KPI drill-down filters — predicates mirror opsStats() exactly so the + // dashboard card's count equals the length of the list it opens. + if (filter.receivedToday) { + base.createdAt = Raw((alias) => `${alias}::date = CURRENT_DATE`); + } + if (filter.pendingInspection) { + base.status = 'RECEIVED'; + base.inspectionStatus = IsNull(); + } + if (filter.agingOverDays) { + if (!filter.status && !filter.pendingInspection) { + base.status = In(this.IN_WAREHOUSE_STATUSES); + } + base.createdAt = Raw((alias) => `${alias} < now() - make_interval(days => :days)`, { + days: filter.agingOverDays, + }); + } + const search = filter.search?.trim(); const where: FindManyOptions['where'] = search ? [ @@ -982,6 +1030,28 @@ export class WarehouseInventoryService { return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); } + /** + * Warehouse location rows for one booking, trimmed for the customer portal: + * no staff guard on the route, so only location fields leave the API — + * never notes, fees or inspection internals. + */ + async bookingLocation(bookingId: string) { + const items = await this.inventoryRepository.findAll({ + where: { bookingId }, + relations: { warehouse: true, yard: true, zone: true }, + order: { createdAt: 'DESC' }, + }); + return items.map((i) => ({ + id: i.id, + bookingId: i.bookingId, + status: i.status, + arrivedAt: i.arrivedAt ?? null, + warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null, + yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null, + zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null, + })); + } + async findById(id: string): Promise { const item = await this.inventoryRepository.findById(id, { relations: { warehouse: { facility: true }, yard: true, zone: true }, @@ -2550,6 +2620,7 @@ export class WarehouseInventoryService { async receive(dto: ReceiveWarehouseInventoryDto): Promise { const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null; + await this.assertExportBookingPaid(dto.bookingId, bookingDirection); const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); @@ -2965,12 +3036,29 @@ export class WarehouseInventoryService { const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() : item.releaseDate ?? null; - const reference = isTruckLeaving - ? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)) - : dto.reference?.trim() || (await this.generateReleaseReference(item)); - const exitInspectionDto = isTruckLeaving - ? this.preserveTruckArrivalForExit(dto, item.notes) - : dto; + // One reference per item — the first truck's arrival mints it, later trucks + // (arrival or exit) reuse it so all exit papers share the release order. + const reference = + item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item)); + const exitInspectionDto = { + ...(isTruckLeaving ? this.preserveTruckArrivalForExit(dto, item.notes) : dto), + }; + // Weighbridge skipped on exit: the recorded net still comes from what the + // truck is holding — the summed cargo weight of its selected containers. + if (isTruckLeaving && exitInspectionDto.weighingSkipped && item.bookingId) { + const selected = (exitInspectionDto.containerNumber ?? '') + .split(/[,;\n]+/) + .map((n) => n.trim()) + .filter(Boolean); + if (selected.length) { + const weights = await this.bookingContainerWeights(item.bookingId); + const byNumber = new Map(weights.map((w) => [w.containerNumber.toUpperCase(), w.weightTons])); + const heldTons = Number( + selected.reduce((sum, n) => sum + (byNumber.get(n.toUpperCase()) ?? 0), 0).toFixed(3), + ); + if (heldTons > 0) exitInspectionDto.netWeight = heldTons; + } + } const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto); // The load actually leaving on this truck, in TONNES (the weighing UI is in @@ -2983,12 +3071,46 @@ export class WarehouseInventoryService { ? Math.round((grossTons - tareTons) * 1000) / 1000 : (exitInspectionDto.netWeight ?? null); + // The weight to record on the inventory when this truck leaves: prefer the + // item's own container cargo weight (a truck may carry other items too); + // fall back to the truck's recorded net. Fills an empty weight only. + let recordedItemTons: number | null = null; + if (isTruckLeaving && netTons != null) { + recordedItemTons = netTons; + if (item.containerId && item.bookingId) { + const [cont]: Array<{ containerNumber: string | null }> = await this.dataSource.query( + `SELECT container_number AS "containerNumber" FROM freight.containers WHERE id = $1`, + [item.containerId], + ); + const ownNumber = cont?.containerNumber?.trim().toUpperCase(); + if (ownNumber) { + const weights = await this.bookingContainerWeights(item.bookingId); + const own = weights.find((w) => w.containerNumber.toUpperCase() === ownNumber); + if (own && own.weightTons > 0) recordedItemTons = own.weightTons; + } + } + } + await this.dataSource.transaction(async (manager) => { await manager.getRepository(WarehouseInventory).update(id, { releaseDate, releaseOrderReference: reference, - notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), + notes: this.replaceExitInspectionNote( + item.notes, + exitInspectionNote, + exitInspectionDto.truckPlateNumber, + ), }); + // Even an unweighed truck records the inventory weight it is holding — + // without this the handover/exit papers print "0 t" for skipped weighings. + if (recordedItemTons != null) { + await manager.query( + `UPDATE freight.warehouse_inventory + SET weight = $2, updated_at = NOW() + WHERE id = $1 AND COALESCE(weight, 0) = 0`, + [id, recordedItemTons], + ); + } if (!isTruckLeaving && item.bookingId) { // Per-truck arrival: mark the customer truck carrying THIS item's // container as arrived (matched via the physical container number). @@ -3051,7 +3173,7 @@ export class WarehouseInventoryService { // EDR last-mile: this truck is leaving — record its exit and the load it // actually took. net_weight_tons drives the bulk drawdown (booking VGM // minus everything already hauled away). - await manager.query( + const [edrDeparted] = (await manager.query( `UPDATE freight.last_mile_vehicle_assignments va SET departed_at = COALESCE($3::timestamptz, NOW()), arrived_at = COALESCE(va.arrived_at, NOW()), @@ -3065,7 +3187,8 @@ export class WarehouseInventoryService { AND v.id = va.vehicle_id AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) AND va.departed_at IS NULL - AND va.deleted_at IS NULL`, + AND va.deleted_at IS NULL + RETURNING va.id`, [ item.bookingId, dto.truckPlateNumber.trim(), @@ -3073,7 +3196,31 @@ export class WarehouseInventoryService { grossTons, netTons, ], - ); + )) as [Array<{ id: string }>, unknown]; + // EDR last-mile: the handover is generated the moment the truck exits + // (with its exit paper) — one per truck — and the customer is asked to + // sign it from the portal. Booking-level fallback when the plate matched + // no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled. + for (const row of edrDeparted) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id }, + manager, + ); + } + if (!edrDeparted.length) { + const [lm]: Array<{ id: string }> = await manager.query( + `SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [item.bookingId], + ); + if (lm) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim() }, + manager, + ); + } + } // Customer self-haul: the same exit record on the customer's own truck. // Without it a self-haul bulk booking never draws down — hauled tonnage // summed to zero and the booking could take unlimited trucks. Matched by @@ -3123,11 +3270,43 @@ export class WarehouseInventoryService { // the transaction and fire-and-forget: notifying must never fail the exit. if (isTruckLeaving && item.bookingId) { void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons); + } else if (item.bookingId) { + // Gate-in: same single-path hook for the arrival side (self-haul + EDR). + void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null); } return this.findById(id); } + /** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */ + private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT company_id AS "companyId", reference + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking?.companyId) return; + const ref = booking.reference ?? bookingId; + const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck'; + const body = `${truck} has arrived at the warehouse for booking ${ref}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Truck arrived at the warehouse', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Best-effort truck-departure notification to the booking's company across * every channel: in-app (portal inbox) + SMS + email. Never throws — a missing @@ -3809,8 +3988,213 @@ export class WarehouseInventoryService { }; } - /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ - async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Customer signs ONE handover from the portal (EDR last-mile: one per truck). + * When the last one is signed — and every EDR truck has left the warehouse — + * the delivery completes automatically: inventory + cargo delivered, last-mile + * leg DELIVERED (trucks freed), booking completed ("shipment delivered"). + */ + async signHandover( + handoverId: string, + userId?: string, + signerName?: string, + ): Promise<{ + handoverId: string; + bookingId: string; + signedAt: string | null; + signerDisplayName: string; + allSigned: boolean; + }> { + if (!userId) { + throw new BadRequestException('Authentication is required to sign the handover'); + } + const name = signerName?.trim(); + if (!name) { + throw new BadRequestException('Please enter your full name to sign the handover'); + } + + const [h]: Array<{ + bookingId: string; + reference: string; + truckPlate: string | null; + mileType: string; + edrAssignmentId: string | null; + }> = await this.dataSource.query( + `SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate", + mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId" + FROM freight.booking_handovers + WHERE id = $1 AND deleted_at IS NULL`, + [handoverId], + ); + if (!h) throw new NotFoundException(`Handover ${handoverId} not found`); + // Self-haul stays a single booking-level signature via approve-delivery, + // which also enforces inspection-passed + truck-arrived. Per-truck signing + // is an EDR last-mile flow only. + if (h.mileType !== 'EDR_LAST_MILE') { + throw new BadRequestException( + 'This handover is signed through Approve delivery, not per truck', + ); + } + + // Same gate as approve-delivery: storage/demurrage must be settled first. + const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`, + [h.bookingId], + ); + if (inv) await this.invoices.assertClearanceAllowed(inv.id); + + const signed = await this.handover.sign(handoverId, userId, name); + const allSigned = await this.handover.isFullySigned(h.bookingId); + + if (inv) { + await this.activityLog.record({ + activityType: 'INVENTORY_RELEASED', + inventoryId: inv.id, + warehouseId: inv.warehouseId, + description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`, + performedBy: name, + }); + } + + // EDR last-mile delivers PER TRUCK: this signature confirms receipt of the + // goods THIS truck carried, so only its containers become DELIVERED now. + // (Self-haul keeps the single booking-level handover + manual Deliver.) + if (h.mileType === 'EDR_LAST_MILE') { + try { + await this.deliverEdrTruckContainers(h, name); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`, + ); + } + } + + if (allSigned) { + void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) => + this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`), + ); + } + + return { + handoverId, + bookingId: h.bookingId, + signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null, + signerDisplayName: name, + allSigned, + }; + } + + /** + * EDR last-mile auto-completion: once every handover is signed and every EDR + * truck has departed, deliver the remaining inventory, mark the last-mile leg + * DELIVERED and complete the booking. Self-haul bookings keep their manual + * Deliver flow (no last_mile record ⇒ no-op). + */ + private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise { + const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query( + `SELECT id, status FROM freight.last_mile + WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!lm) return; + + const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [bookingId], + ); + if (Number(pending?.notDeparted ?? 0) > 0) return; + if (!(await this.handover.isFullySigned(bookingId))) return; + + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`, + [bookingId], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: 'Auto-delivered on customer handover signature', + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`); + } + } + + if (lm.status !== 'DELIVERED') { + try { + await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto); + } catch (err) { + this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`); + } + } + + // Booking → COMPLETED ("shipment delivered" notification) — owned by the + // bookings module; evented to avoid a warehouses→bookings service dependency. + this.events.emit('import.handover.completed', { bookingId }); + } + + /** + * EDR last-mile per-truck delivery: the customer signed THIS truck's handover, + * so only the container items that truck carried become DELIVERED. Bulk cargo + * (no container rows) is delivered by completeEdrDeliveryIfReady once every + * truck is signed off. + */ + private async deliverEdrTruckContainers( + h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null }, + signerName: string, + ): Promise { + if (!h.edrAssignmentId && !h.truckPlate) return; + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT DISTINCT inv.id + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id + JOIN freight.containers c + ON c.container_number = COALESCE(vc.container_number, va.container_number) + AND c.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL + WHERE l.booking_id = $1 + AND va.deleted_at IS NULL + AND inv.status = 'READY_FOR_PICKUP' + AND (va.id = $2::uuid + OR ($2::uuid IS NULL + AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`, + [h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`, + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`, + ); + } + } + } + + /** + * Handover PDF resolved by booking (for the portal, which only has bookingId). + * With `handoverId` the document is rendered for that specific handover — the + * per-truck EDR last-mile variant (truck plate + that truck's signature state). + */ + async handoverDocumentForBooking( + bookingId: string, + handoverId?: string, + ): Promise<{ filename: string; buffer: Buffer }> { const [inv]: Array<{ id: string }> = await this.dataSource.query( `SELECT id FROM freight.warehouse_inventory WHERE booking_id = $1 AND deleted_at IS NULL @@ -3821,7 +4205,29 @@ export class WarehouseInventoryService { if (!inv) { throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); } - return this.handoverDocument(inv.id); + if (!handoverId) return this.handoverDocument(inv.id); + + const [h]: Array<{ + reference: string; + truckPlate: string | null; + signedAt: string | null; + signerName: string | null; + }> = await this.dataSource.query( + `SELECT reference, truck_plate AS "truckPlate", + signed_at AS "signedAt", signer_name AS "signerName" + FROM freight.booking_handovers + WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`, + [handoverId, bookingId], + ); + if (!h) { + throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id, { + reference: h.reference, + truckPlate: h.truckPlate, + signedAt: h.signedAt ? new Date(h.signedAt) : null, + signerName: h.signerName, + }); } /** Resolve the primary warehouse-inventory item for a booking (most recent). */ @@ -3849,12 +4255,22 @@ export class WarehouseInventoryService { return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId)); } - async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + async handoverDocument( + id: string, + perTruck?: { + reference: string; + truckPlate: string | null; + signedAt: Date | null; + signerName: string | null; + }, + ): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", inv.quantity, - inv.weight, + -- An unweighed item still reports the cargo weight it holds: fall + -- back to the item's container VGM when no weight was recorded. + COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, 0) AS weight, inv.status, inv.notes, inv.inspection_status AS "inspectionStatus", @@ -3906,6 +4322,16 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT SUM(bcu.vgm_tons) AS tons + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc2 + ON bc2.id = bcu.booking_container_id AND bc2.deleted_at IS NULL + WHERE bc2.booking_id = b.id + AND bcu.deleted_at IS NULL + AND (container.container_number IS NULL + OR bcu.container_number = container.container_number) + ) item_vgm ON true LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL @@ -3924,12 +4350,14 @@ export class WarehouseInventoryService { const bookingReference = row.bookingReference || row.bookingId || 'N/A'; const reference = + perTruck?.reference || this.extractHandoverDocumentLine(row.notes, 'Handover Reference') || `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`; const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At'); const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date(); const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt; - if (!generatedAtValue) { + // Per-truck renders must not stamp their reference into the shared item notes. + if (!generatedAtValue && !perTruck) { await this.inventoryRepository.update(id, { notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)), }); @@ -3963,7 +4391,16 @@ export class WarehouseInventoryService { releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, trainSchedule: row.trainSchedule ?? null, lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null, - customerApproval: this.extractCustomerDeliveryApproval(row.notes), + truckPlate: perTruck?.truckPlate ?? null, + customerApproval: perTruck + ? perTruck.signedAt + ? { + approvedAt: perTruck.signedAt.toISOString(), + signerDisplayName: perTruck.signerName ?? '-', + signatureImageUrl: null, + } + : null + : this.extractCustomerDeliveryApproval(row.notes), }); return { @@ -4002,14 +4439,61 @@ export class WarehouseInventoryService { if (!(await this.handover.isFullySigned(item.bookingId))) { throw new BadRequestException('Handover must be signed before delivery'); } - const [left]: Array<{ n: string }> = await this.dataSource.query( - `SELECT COUNT(*) AS n FROM freight.customer_truck_assignments - WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`, + const [trucks]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE departed_at IS NOT NULL) AS "left" + FROM freight.customer_truck_assignments + WHERE booking_id = $1 AND deleted_at IS NULL`, [item.bookingId], ); - if (Number(left?.n ?? 0) === 0) { + const totalTrucks = Number(trucks?.total ?? 0); + const leftTrucks = Number(trucks?.left ?? 0); + if (leftTrucks === 0) { throw new BadRequestException('Deliver is available only after the customer truck has left'); } + // Multi-truck booking: every assigned truck must arrive and leave — + // each is weighed out separately before the goods count as delivered. + if (leftTrucks < totalTrucks) { + throw new BadRequestException( + `Deliver is available only after every assigned truck has left (${leftTrucks} of ${totalTrucks} so far)`, + ); + } + } + // EDR last-mile delivers per truck: a container item only needs the truck + // CARRYING IT to have left; bulk (no container) waits for every truck. + if (item.containerId) { + const [own]: Array<{ pending: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND COALESCE(vc.container_number, va.container_number) = c.container_number`, + [item.bookingId, item.containerId], + ); + if (Number(own?.pending ?? 0) > 0) { + throw new BadRequestException( + 'Deliver is available only after the EDR truck carrying this container has left', + ); + } + } else { + const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [item.bookingId], + ); + const lmTotal = Number(lm?.total ?? 0); + const lmLeft = Number(lm?.left ?? 0); + if (lmTotal > 0 && lmLeft < lmTotal) { + throw new BadRequestException( + `Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`, + ); + } } } @@ -4105,6 +4589,12 @@ export class WarehouseInventoryService { } }); + // "Approve delivery" nudge: on Deliver the customer is reminded to sign any + // handover still unsigned (per truck for EDR last-mile). Fire-and-forget. + if (item.bookingId) { + void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined); + } + return this.findById(id); } @@ -4832,10 +5322,11 @@ export class WarehouseInventoryService { releaseDate: Date | null; trainSchedule: string | null; lastMileDeliveryAddress: string | null; + truckPlate?: string | null; customerApproval: { approvedAt: string; signerDisplayName: string; - signatureImageUrl: string; + signatureImageUrl: string | null; } | null; }): string { const esc = (value: unknown) => @@ -4881,6 +5372,7 @@ export class WarehouseInventoryService { ['Release Order', data.releaseOrderReference], ['Release Date', fmt(data.releaseDate)], ['Last-mile Delivery Address', data.lastMileDeliveryAddress], + ...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []), ]; const approval = data.customerApproval; @@ -5290,7 +5782,11 @@ export class WarehouseInventoryService { 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`, + // Skipped weighing still records a net — the cargo weight of the + // containers the truck is holding, resolved by the caller. + (computedNetWeight ?? (weighingSkipped ? dto.netWeight : null)) == null + ? null + : `Net Weight: ${computedNetWeight ?? Number(dto.netWeight)} t`, dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null, ]; @@ -5298,12 +5794,14 @@ export class WarehouseInventoryService { } private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto { - const inspection = this.extractExitInspectionNote(notes); + const inspection = this.extractExitInspectionForPlate(notes, dto.truckPlateNumber); if (!inspection) return dto; return { ...dto, - truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, + // The submitted plate wins: a legacy block may store a comma-joined list + // of plates, and the exit must be recorded against the ONE truck leaving. + truckPlateNumber: dto.truckPlateNumber?.trim() || this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber, trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber, driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName, driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense, @@ -5317,25 +5815,94 @@ export class WarehouseInventoryService { }; } - private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null { + /** + * Split notes into exit-inspection blocks (one per truck, in order) and + * everything else. A block ends at the first line that isn't one of the + * known inspection labels, so appended notes (delivery approval, handover + * marker) are preserved as "other" content instead of being swallowed by + * the block they happen to follow. + */ + private splitExitInspectionSections(notes?: string | null): { others: string[]; blocks: string[] } { const trimmed = notes?.trim(); - if (!exitInspectionNote) return trimmed || null; - if (!trimmed) return exitInspectionNote; - - const marker = '[Exit Inspection]'; - const index = trimmed.lastIndexOf(marker); - if (index < 0) { - return `${trimmed}\n\n${exitInspectionNote}`; + if (!trimmed) return { others: [], blocks: [] }; + const labelPattern = + /^(Booking ID|Customer ID|Truck Plate|Trailer Plate|Driver|Driver License|Driver Phone|Truck Type|Container Number|Gate In Time|Weighing|Tare Weight|Gross Weight|Net Weight|Gate Out Time):/i; + const parts = trimmed.split(EXIT_INSPECTION_MARKER); + const others: string[] = []; + const blocks: string[] = []; + if (parts[0]?.trim()) others.push(parts[0].trim()); + for (const part of parts.slice(1)) { + const lines = part.split('\n'); + const kept: string[] = []; + let i = 0; + while (i < lines.length && !lines[i].trim()) i += 1; + for (; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (!line || !labelPattern.test(line)) break; + kept.push(line); + } + if (kept.length) blocks.push(kept.join('\n')); + const tail = lines.slice(i).join('\n').trim(); + if (tail) others.push(tail); } - return [trimmed.slice(0, index).trim(), exitInspectionNote].filter(Boolean).join('\n\n'); + return { others, blocks }; } + /** + * A block belongs to a plate when its stored `Truck Plate` equals it, or is a + * legacy comma-joined list ("P1, P2") containing it. + */ + private blockMatchesPlate(block: string, plateNumber?: string | null): boolean { + const plate = plateNumber?.trim().toUpperCase(); + if (!plate) return false; + const stored = this.extractExitInspectionLine(block, 'Truck Plate')?.toUpperCase(); + if (!stored) return false; + if (stored === plate) return true; + return stored.split(/[,;]+/).map((p) => p.trim()).includes(plate); + } + + /** + * Replace THIS truck's inspection block (matched by plate), keeping every + * other truck's block untouched; append when the plate has no block yet. + * A single legacy block (comma-joined plates or plate-less caller) is + * replaced in place so old single-truck items keep their behaviour. + */ + private replaceExitInspectionNote( + notes: string | null | undefined, + exitInspectionNote: string | null, + plateNumber?: string | null, + ): string | null { + const { others, blocks } = this.splitExitInspectionSections(notes); + if (exitInspectionNote) { + const content = exitInspectionNote.replace(EXIT_INSPECTION_MARKER, '').trim(); + const index = plateNumber + ? blocks.findIndex((b) => this.blockMatchesPlate(b, plateNumber)) + : blocks.length - 1; + if (index >= 0) blocks[index] = content; + else blocks.push(content); + } + const sections = [...others, ...blocks.map((b) => `${EXIT_INSPECTION_MARKER}\n${b}`)]; + return sections.join('\n\n') || null; + } + + /** Latest truck's inspection block — legacy summary for documents. */ private extractExitInspectionNote(notes?: string | null): string | null { - if (!notes) return null; - const marker = '[Exit Inspection]'; - const index = notes.lastIndexOf(marker); - if (index < 0) return null; - return notes.slice(index + marker.length).trim() || null; + const { blocks } = this.splitExitInspectionSections(notes); + return blocks.length ? blocks[blocks.length - 1] : null; + } + + /** + * The inspection block for one truck. Falls back to a lone existing block so + * legacy single-truck items (saved before per-plate blocks) keep working. + */ + private extractExitInspectionForPlate( + notes: string | null | undefined, + plateNumber?: string | null, + ): string | null { + const { blocks } = this.splitExitInspectionSections(notes); + const match = blocks.find((b) => this.blockMatchesPlate(b, plateNumber)); + if (match) return match; + return blocks.length === 1 ? blocks[0] : null; } private extractExitInspectionLine(note: string | null | undefined, label: string): string | null { @@ -5648,6 +6215,31 @@ export class WarehouseInventoryService { ); } + /** + * Export cargo is received into the warehouse to wait for its train, and it is + * received only once the booking is paid — receiving an unpaid export booking + * would start storage and mint a GRN against cargo the customer has not settled. + * + * Export only: import cargo arrives OFF a train and its receive is the unload, + * so gating that on payment would strand cargo already at the yard. + */ + private async assertExportBookingPaid( + bookingId: string | null | undefined, + direction: string | null, + ): Promise { + if (!bookingId || direction !== 'EXPORT') return; + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') { + throw new BadRequestException( + 'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.', + ); + } + } + private assertCapacity( label: string, node: LocationNode, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index fdfbc36be..5f4205815 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -10,8 +10,9 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() +// No class-level guard: the two reference GETs are open to any signed-in +// staff (StaffReference), every other route carries its own permission. @Controller('warehouse-yards') -@BookingStaff(FREIGHT_PERMS.warehouseYards.view) export class WarehouseYardsController { constructor( private readonly yardsService: WarehouseYardsService, @@ -19,12 +20,14 @@ export class WarehouseYardsController { ) {} @Get() + @StaffReference() @ApiOperation({ summary: 'List all warehouse yards' }) findAll() { return this.yardsService.findAll(); } @Get(':id') + @StaffReference() @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.yardsService.findById(id); diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index d65b2e2d8..03908b62b 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -568,6 +568,20 @@ const DRIVER_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ }, ]; +// ── Intercity documents ───────────────────────────────────────────────────── +// One shared set for DOMESTIC (intercity) shipments, reviewed by Operations. +// ONE_TIME contracts collect it at contract level after both signatures; +// GENERAL contracts collect it per booking right after the booking is created. +// Fields start empty and are configured in the backoffice file-settings editor. +const INTERCITY_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "intercity_documents", + label: "Intercity documents", + entity: "booking", + fields: [], + }, +]; + @Injectable() export class FileUploadSettingsSeeder { private readonly logger = new Logger(FileUploadSettingsSeeder.name); @@ -619,6 +633,11 @@ export class FileUploadSettingsSeeder { description: "Documents uploaded against a driver profile (license, ID, contracts, etc.).", })), + ...INTERCITY_DOCUMENT_SETTINGS.map((s) => ({ + ...s, + description: + "Intercity shipment documents — contract-level for ONE_TIME (after both signatures), per booking for GENERAL; reviewed by Operations.", + })), ]; // Insert setting rows only — no FileUploadField rows. Fields start empty diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index d3b5bbb00..396ac95db 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -18,6 +18,7 @@ export const RULE_ENGINE_RESOURCE_SLUGS = [ 'priority-configs', 'rates', 'approval-rules', + 'yard-distances', ] as const; export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; @@ -97,6 +98,7 @@ const RULE_ENGINE_PERMISSION_IDS: Record [ label: "Customers", href: "/dashboard/customers", icon: , + permission: FREIGHT_PERMS.customers.view, }, { label: "Contracts", @@ -162,6 +163,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Bookings", href: "/dashboard/booking-requests", icon: , + permission: FREIGHT_PERMS.bookings.view, }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). @@ -375,6 +377,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Imports", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Import Overview", @@ -407,6 +410,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Exports", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Export Overview", @@ -449,6 +453,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Intercity", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, children: [ { label: "Intercity Cargo", @@ -465,6 +470,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Warehouse Dashboard", href: "/dashboard/warehouse-dashboard", icon: , + permission: FREIGHT_PERMS.warehouseDashboard.view, }, { // Yard-wide, not per-direction: the gate sees import and export @@ -472,21 +478,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Trucks on Site", href: "/dashboard/trucks-on-site", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Warehouses", href: "/dashboard/warehouses", icon: , + permission: FREIGHT_PERMS.warehouses.view, }, { label: "Allocation & Fees", href: "/dashboard/warehouse-rules", icon: , + permission: FREIGHT_PERMS.warehouseAllocationRules.view, }, { label: "Fee Invoices", href: "/dashboard/warehouse-fee-invoices", icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, }, ], }, @@ -523,10 +533,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { label: "Contract validity", href: "/dashboard/configuration/contract-validity-periods", + permission: FREIGHT_PERMS.config.contractValidity.view, }, { label: "Train scheduling rules", href: "/dashboard/configuration/train-scheduling-rules", + permission: FREIGHT_PERMS.trainScheduling.rulesManage, }, ], }, @@ -541,6 +553,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Staff", href: "/user-management", icon: , + permission: FREIGHT_PERMS.admin, }, ], }, @@ -596,10 +609,22 @@ const filterSidebarByPermission = ( return permissionAllowed(item); }; + // Recursive: a group's own permission gates the whole subtree, leaves are + // checked individually, and a group with no surviving children disappears. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items.flatMap((item) => { + if (item.children?.length) { + if (item.permission && !permissionAllowed(item)) return []; + const children = filterItems(item.children); + return children.length ? [{ ...item, children }] : []; + } + return itemAllowed(item) ? [item] : []; + }); + return sections .map((section) => ({ ...section, - items: section.items.filter(itemAllowed), + items: filterItems(section.items), })) .filter((section) => section.items.length > 0); }; @@ -708,7 +733,7 @@ const App = () => { } /> {/* } /> */} } /> - } /> + } /> } /> ); diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index b31fd836a..82ae8e808 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import toast from "react-hot-toast"; import { API_BASE_URL } from "@/constants/apiConfig"; import { captureApiError } from "@/lib/posthog"; @@ -10,14 +11,16 @@ import { setCookie, } from "./cookies"; import type { AuthTokens } from "./types"; +import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal"; declare module "axios" { export interface AxiosRequestConfig { /** - * When true, the response interceptor does NOT raise the global error modal - * for this request's failure. For calls the caller handles itself — e.g. a - * probe that is expected to 404 before falling back (GL clearance detail - * tries /contracts/:id then /bookings/:id). The rejection still propagates. + * When true, the response interceptor does NOT raise the global error + * toast for this request's failure. For calls the caller handles itself — + * e.g. a probe that is expected to 404 before falling back (GL clearance + * detail tries /contracts/:id then /bookings/:id). The rejection still + * propagates. */ suppressErrorModal?: boolean; } @@ -92,9 +95,8 @@ api.interceptors.response.use( async (error) => { const originalRequest = error.config as RetriableRequest | undefined; - // Report the failure to PostHog. Hooked here rather than inside - // `emitApiError`, which stays silent on suppressed paths (warehouse / - // mile / onboarding) — those failures still need reporting. + // Report the failure to PostHog, including on suppressErrorModal paths — + // those opt out of the user-facing toast, not of reporting. // 401s are skipped: an expired session is refreshed below, not a defect. if (!error.response || error.response.status !== 401) { captureApiError(error); @@ -108,16 +110,25 @@ api.interceptors.response.use( originalRequest.url?.includes("/auth/mfa-verify") || originalRequest.url?.includes("/auth/refresh-token") ) { - // Surface the server's actual error message in the global error modal - // (401s are handled by the session-refresh flow, so skip them). A request - // may opt out via `suppressErrorModal` when it handles the failure itself. - if ( - error.response && - error.response.status !== 401 && - !originalRequest?.suppressErrorModal - ) { - // const payload = extractApiErrorPayload(error); - // if (payload) emitApiError(payload); + // Surface the server's actual error message in a global toast — never + // the error modal (401s are handled by the session-refresh flow, so skip + // them). A request may opt out via `suppressErrorModal` when it handles + // the failure itself. + if (error.response && error.response.status !== 401) { + const payload = extractApiErrorPayload(error); + // Normalize the error's own `message` to the SERVER's actual message so + // every downstream `toast.error(err.message)` handler shows the real + // cause instead of "Request failed with status code NNN". Applies even + // on suppressErrorModal paths — only the toast is opted out. + if (payload?.messages.length) { + const message = payload.messages.join("\n"); + (error as { message?: string }).message = message; + // Keyed by message so a retried request replaces its toast instead + // of stacking duplicates. + if (!originalRequest?.suppressErrorModal) { + toast.error(message, { id: message }); + } + } } return Promise.reject(error); } diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 2dac243b4..a361c2abc 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -23,6 +23,7 @@ interface AuthEmployeePosition { permissions?: AuthPermission[]; /** Some IAM payloads nest the position record instead of flattening its key. */ position?: { id?: string; key?: string; name?: LocaleText }; + positionType?: { id?: string; key?: string; name?: LocaleText } | null; } interface AuthEmployeeRecord { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 97573410f..dcdfe6e39 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react"; +import { Check, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -8,6 +8,7 @@ import { Button, Box, Modal, + Select, Textarea, } from "@mantine/core"; import type { Freight } from "@edr/types"; @@ -35,6 +36,10 @@ export function ContractApprovalStepsCard({ const [rejectStepRow, setRejectStepRow] = useState(null); const [rejectReason, setRejectReason] = useState(""); + // Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an + // earlier APPROVED step to send the chain back to. First approver has no + // choice — customer only. + const [rejectTarget, setRejectTarget] = useState("CUSTOMER"); const steps = useMemo( () => @@ -46,6 +51,11 @@ export function ContractApprovalStepsCard({ const nextPending = steps.find((s) => s.status === "PENDING"); const summary = formatContractApprovalProgress(contract.status, steps); + // The card also renders read-only trails (e.g. a REJECTED contract) — only + // offer approve/reject while the backend accepts step actions. + const actionable = + contract.status === "PENDING_APPROVAL" || + contract.status === "APPROVED_PENDING_SIGNATURE"; // Approvers review a live preview of the document; there is no PDF to // generate first — the final approval is what produces it. @@ -70,6 +80,7 @@ export function ContractApprovalStepsCard({ const openReject = (step: Freight.IContractApprovalStep) => { setRejectStepRow(step); setRejectReason(""); + setRejectTarget("CUSTOMER"); setRejectOpen(true); }; @@ -77,14 +88,34 @@ export function ContractApprovalStepsCard({ setRejectOpen(false); setRejectStepRow(null); setRejectReason(""); + setRejectTarget("CUSTOMER"); }; const trimmedReason = rejectReason.trim(); + // Earlier stages this rejection can be returned to — only stages that have + // already approved. Empty for the first approver, whose only target is the + // customer. + const returnableSteps = rejectStepRow + ? steps.filter( + (s) => + s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED", + ) + : []; + + const sendBack = rejectTarget !== "CUSTOMER"; + const targetStep = sendBack + ? returnableSteps.find((s) => s.id === rejectTarget) + : undefined; + const runReject = () => { if (!rejectStepRow || !trimmedReason) return; mutations.rejectStep.mutate( - { stepId: rejectStepRow.id, reason: trimmedReason }, + { + stepId: rejectStepRow.id, + reason: trimmedReason, + returnToStepId: sendBack ? rejectTarget : undefined, + }, { onSuccess: () => closeReject() }, ); }; @@ -134,7 +165,7 @@ export function ContractApprovalStepsCard({ - - Rejecting the{" "} - - {rejectStepRow?.requiredRole} - {" "} - step rejects contract{" "} - - {contract.reference} - {" "} - outright. The customer must create a new contract — this cannot be - undone. - + {returnableSteps.length > 0 && ( + -

Supports any format: one per line, comma-separated, or {REF1,REF2} groups.

- -
- - - - -
-
-
-
- - -
-
- -
- - - - - -
-
- - - - - - - -
- -
- - - - - - - - -
JourneyDuplicate Bookings
-
-
- - - - - diff --git a/booking-extractor.html b/booking-extractor.html deleted file mode 100644 index 844c98b2c..000000000 --- a/booking-extractor.html +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - EDR Booking Extractor - - - - -

EDR Booking Extractor

- -
- -
- - Drop bookings.json here or click to browse -
-

Accepts a JSON array of bookings or an object with a bookings key.

-
- - - -
-
- -
-
-
- - - -
-
- - - - - - - - - - - - - - - - - - - - - -
#Booking RefStatusBooking TypePhoneEmailDepartureOriginDestinationPassenger(s)Coach - SeatPayment MethodPayment StatusTotal (DJF)Created At
-
-
- - - - - diff --git a/booking-proxy.mjs b/booking-proxy.mjs deleted file mode 100644 index 27f9248ee..000000000 --- a/booking-proxy.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import http from 'http'; -import https from 'https'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const PORT = 8080; -const __dir = path.dirname(fileURLToPath(import.meta.url)); - -const server = http.createServer((req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } - - // Serve any .html file in the same directory - if (req.url === '/' || req.url.endsWith('.html')) { - const filename = req.url === '/' ? 'booking-checker.html' : req.url.slice(1); - const filepath = path.join(__dir, filename); - if (fs.existsSync(filepath)) { - res.writeHead(200, { 'Content-Type': 'text/html' }); - fs.createReadStream(filepath).pipe(res); - } else { - res.writeHead(404); res.end('Not found'); - } - return; - } - - // Proxy /proxy?url= - if (req.url.startsWith('/proxy?url=')) { - const target = decodeURIComponent(req.url.slice('/proxy?url='.length)); - const parsed = new URL(target); - const mod = parsed.protocol === 'https:' ? https : http; - const options = { - hostname: parsed.hostname, - port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), - path: parsed.pathname + parsed.search, - method: req.method, - headers: { ...req.headers, host: parsed.hostname }, - }; - const proxy = mod.request(options, (apiRes) => { - res.writeHead(apiRes.statusCode, apiRes.headers); - apiRes.pipe(res); - }); - proxy.on('error', (e) => { res.writeHead(502); res.end(e.message); }); - req.pipe(proxy); - return; - } - - res.writeHead(404); res.end(); -}); - -server.listen(PORT, () => console.log(`Booking checker: http://localhost:${PORT}/booking-checker.html`)); diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml new file mode 100644 index 000000000..b00402761 --- /dev/null +++ b/docker-compose.e2e.yaml @@ -0,0 +1,194 @@ +# EDR Freight — ephemeral Cypress e2e stack. +# Fully isolated from dev: own ports, own throwaway Postgres (tmpfs — data +# vanishes on `down`), seeded test users. Requires the same .npmrc as the main +# docker-compose.yaml (GitHub Packages auth for @tria-plc). +# +# Preferred entrypoint: the launcher (auto-up + free-port picking): +# pnpm e2e:freight:run|open|ci|up|down → e2e/freight/scripts/e2e.mjs +# +# Host ports are env-parameterized (E2E_*_PORT). Defaults below avoid the dev +# stacks (5273/5283/3221 are taken by the second dev checkout in +# ~/projects/nathnael/edr-platform); when a default is busy the launcher scans +# upward for a free port and remembers the choice in e2e/freight/.e2e-ports.json +# while the stack is up: +# freight-api 3101 portal 5373 backoffice 5383 +# postgres 5533 minio 9310 (console 9311) +name: edr-freight-e2e + +services: + postgres-freight-e2e: + image: postgres:16-alpine + environment: + POSTGRES_DB: edr_freight_e2e + POSTGRES_USER: edr_e2e + POSTGRES_PASSWORD: edr_e2e + tmpfs: + - /var/lib/postgresql/data + ports: + - "${E2E_DB_PORT:-5533}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U edr_e2e -d edr_freight_e2e"] + interval: 2s + timeout: 3s + retries: 30 + + minio-e2e: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: e2e-minio + MINIO_ROOT_PASSWORD: e2e-minio-secret + tmpfs: + - /data + ports: + - "${E2E_MINIO_PORT:-9310}:9000" + - "${E2E_MINIO_CONSOLE_PORT:-9311}:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 12 + + # tmpfs wipes MinIO on every restart — recreate the app bucket each boot. + minio-init-e2e: + image: minio/mc:latest + depends_on: + minio-e2e: + condition: service_healthy + entrypoint: + - /bin/sh + - -c + - mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc + restart: "no" + + freight-api-e2e: + build: + context: . + dockerfile: apps/edr-freight-api/Dockerfile + secrets: + - npmrc + depends_on: + postgres-freight-e2e: + condition: service_healthy + minio-e2e: + condition: service_healthy + minio-init-e2e: + condition: service_completed_successfully + environment: + PORT: "3001" + DB_HOST: postgres-freight-e2e + DB_PORT: "5432" + DB_USER: edr_e2e + DB_PASSWORD: edr_e2e + DB_NAME: edr_freight_e2e + # e2e-only secrets — never reuse outside this stack + JWT_SECRET: e2e-jwt-secret + JWT_ACCESS_TOKEN_SECRET: e2e-access-secret + JWT_REFRESH_TOKEN_SECRET: e2e-refresh-secret + JWT_EXPIRES_IN: 1d + JWT_ACCESS_TOKEN_EXPIRES: 1d + JWT_REFRESH_TOKEN_EXPIRES: 7d + SERVICE_AUTH_TOKEN: e2e-service-token + # Org/unit/position boot seeders (env-gated in app code). Test USERS are + # NOT seeded by the API — Cypress inserts them via + # e2e/freight/cypress/fixtures/seed-users.sql before specs run. + SEED_EDR_ORG: "true" + SUPER_ADMIN_EMAIL: superadmin@tria.com + SUPER_ADMIN_PHONE: "+251900000000" + # Object storage + MINIO_ENDPOINT: minio-e2e + MINIO_PORT: "9000" + MINIO_USE_SSL: "false" + MINIO_ACCESS_KEY: e2e-minio + MINIO_SECRET_KEY: e2e-minio-secret + MINIO_REGION: us-east-1 + # External integrations off + RABBITMQ_ENABLED: "false" + FAYDA_ENABLED: "false" + # SMS strategy has no kill switch and defaults to a real dev endpoint — + # blackhole it so e2e never sends SMS (failures are logged, non-fatal). + OZIKING_SMS_URL: http://127.0.0.1:9/sms + FREIGHT_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} + ports: + - "${E2E_API_PORT:-3101}:3001" + healthcheck: + # Boot runs 240+ migrations + seeders on first start — generous start_period. + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 5s + timeout: 5s + retries: 12 + start_period: 180s + + freight-portal-e2e: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.web + args: + TURBO_FILTER: "@edr/freight-portal" + APP_PATH: apps/edr-freight-web/portal + # Baked at build time: browser (host or host-networked cypress + # container) reaches the API through the published host port. A + # non-default API port therefore forces a web image rebuild. + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_USER_MANAGEMENT_BASE: /_um + VITE_GOOGLE_MAPS_API_KEY: "" + VITE_POSTHOG_KEY: "" + VITE_POSTHOG_HOST: "" + secrets: + - npmrc + ports: + - "${E2E_PORTAL_PORT:-5373}:80" + + freight-backoffice-e2e: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.web + args: + TURBO_FILTER: "@edr/freight-backoffice" + APP_PATH: apps/edr-freight-web/backoffice + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_USER_MANAGEMENT_BASE: /_um + VITE_GOOGLE_MAPS_API_KEY: "" + VITE_POSTHOG_KEY: "" + VITE_POSTHOG_HOST: "" + secrets: + - npmrc + ports: + - "${E2E_BACKOFFICE_PORT:-5383}:80" + + # Headless runner — opt-in via `--profile cypress`. host network so the + # in-container browser uses the exact same localhost URLs as `cypress open` + # on the host (Linux only; on macOS/Windows run Cypress from the host). + cypress: + # Keep in sync with the cypress version in e2e/freight/package.json. + image: cypress/included:${CYPRESS_VERSION:-15.18.1} + profiles: ["cypress"] + network_mode: host + depends_on: + freight-api-e2e: + condition: service_healthy + working_dir: /repo/e2e/freight + # NOTE: host network shares the abstract X-socket namespace with the host. + # Cypress spawns its Xvfb on :99 — run only ONE cypress container at a + # time, and don't run it on a host whose X server occupies :99. + entrypoint: ["cypress", "run", "--browser", "chrome"] + environment: + CI: "true" + E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:${E2E_DB_PORT:-5533}/edr_freight_e2e + CYPRESS_BASE_URL: http://localhost:${E2E_BACKOFFICE_PORT:-5383} + CYPRESS_API_URL: http://localhost:${E2E_API_PORT:-3101} + CYPRESS_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} + volumes: + - .:/repo + +secrets: + npmrc: + file: .npmrc diff --git a/e2e/freight/README.md b/e2e/freight/README.md new file mode 100644 index 000000000..75b877b84 --- /dev/null +++ b/e2e/freight/README.md @@ -0,0 +1,108 @@ +# @edr/freight-e2e — Cypress e2e suite for the freight system + +Containerized, fully isolated e2e environment: throwaway Postgres (tmpfs), +MinIO, freight-api, portal, and backoffice — plus a Cypress runner that works +both headless-in-Docker and interactively from the host against the same URLs. + +## Stack (`docker-compose.e2e.yaml`, project name `edr-freight-e2e`) + +| Service | Default port | Notes | +| ----------------------- | ------------ | ---------------------------------------------- | +| `freight-api-e2e` | 3101 | migrations + seeders run at boot | +| `freight-portal-e2e` | 5373 | nginx static build, API URL baked at build | +| `freight-backoffice-e2e`| 5383 | nginx static build, API URL baked at build | +| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` | +| `minio-e2e` | 9310/9311 | object storage for file features | +| `cypress` | (host net) | profile `cypress`, headless chrome | + +Ports are env-parameterized (`E2E_API_PORT`, `E2E_PORTAL_PORT`, +`E2E_BACKOFFICE_PORT`, `E2E_DB_PORT`, `E2E_MINIO_PORT`, +`E2E_MINIO_CONSOLE_PORT`). Defaults avoid the dev stacks; if a default is +busy anyway, the launcher scans upward for a free port, remembers the choice +in `.e2e-ports.json` (gitignored) while the stack is up, and passes matching +URLs to both compose and Cypress. The dev database is never touched. + +## Usage (from repo root) + +One command — the launcher (`scripts/e2e.mjs`) auto-builds and starts the +stack if it isn't running, waits for healthchecks, then runs Cypress against +whatever ports were picked: + +```bash +pnpm e2e:freight:run # headless run from the host (auto-up) +pnpm e2e:freight:open # interactive Cypress on the host (auto-up) +pnpm e2e:freight:ci # headless run inside the cypress container (auto-up) +pnpm e2e:freight:up # just start the stack +pnpm e2e:freight:down # teardown, drop all data + forget ports +pnpm e2e:freight:run --spec 'cypress/e2e/flows/**' # extra args → cypress +``` + +First `up` is slow (image builds + 240 migrations + seeders — healthcheck +allows 3 min). Later runs against a live stack skip docker entirely. Requires +the same root `.npmrc` (GitHub Packages auth for `@tria-plc`) as the main +compose file. Note: a non-default API port forces a web-image rebuild (the +API URL is baked into the static builds). + +The `cypress` service uses `network_mode: host` (Linux). On macOS/Windows run +Cypress from the host (`e2e:freight:open` / `e2e:freight:run`) instead of the +container. + +## Test users + +Inserted by Cypress itself — a global `before()` hook runs +`cy.task("db:seedUsers")`, which executes `cypress/fixtures/seed-users.sql` +then `cypress/fixtures/seed-company.sql` (idempotent, pre-hashed argon2 +passwords) against the e2e database. No API code is involved; the app's user +seeders stay disabled. The API's always-on boot seeders must have run first +(org/unit/positions) — guaranteed once `freight-api-e2e` is healthy. + +- Staff (backoffice): `linestaff|chief|director|ceo|marketer|operation|gl-et|gl-dj@edr.local` + — password `password@tria` +- Customers (portal): `user@gmail.com`, `user2@gmail.com` + — password `12345678` + +`seed-company.sql` additionally gives `user@gmail.com` an ACTIVE company +("E2E Logistics PLC", TIN `0102030405`) with an approved importer profile — +the contract wizard's precondition — and grants `chief` the +`edr_freight_app:admin` permission (customer-profile approval is +FreightAdmin-guarded and no seeded position carries it otherwise). + +Full map in `cypress/fixtures/users.json`. + +## Conventions + +- **Programmatic login** everywhere except the two dedicated UI-login specs: + `cy.loginBackoffice(email?)` / `cy.loginPortal(email?)` — `cy.session`-cached + (across specs), `POST /api/auth/login`, sets the `auth-token` / + `refresh-token` cookies the apps read. +- **Origins**: `baseUrl` is the backoffice (5383). Portal specs `cy.visit` + the absolute portal URL; a test that touches *both* apps wraps portal steps + in `cy.origin()` (different port = different origin). Cookies ignore ports — + always call the matching login command right before switching apps so + `cy.session` restores the right cookie snapshot. +- **DB access**: `cy.task("db:query", { sql, params })` runs SQL against the + e2e database (`E2E_DB_URL`, default `localhost:5533`). Use for seeding + edge-case data and asserting side effects — it can never reach the dev DB. +- **OTPs**: SMS/email delivery is disabled in e2e, but codes are still stored + in `freight.otp_verifications` — `cy.getOtp(emailOrPhone)` polls them out. + Used by signup verification and contract customer-signing. +- **Spec layout**: + - `cypress/e2e/api/` — API contract via `cy.request` (no browser) + - `cypress/e2e/backoffice/` — staff app + - `cypress/e2e/portal/` — customer app + - `cypress/e2e/flows/` — cross-app journeys (both directions): + - `onboarding.cy.ts` — signup → OTP → wizard (docs + license upload) → + backoffice approval → customer can contract + - `contract-lifecycle.cy.ts` — wizard → submit → accept → 2-step approval + → PDF → customer OTP-sign → staff counter-sign → `CONTRACT_ACTIVE` +- **Journey specs** (`flows/onboarding`, `flows/contract-lifecycle`) run with + `retries: 0` and resolve mid-journey state (user, company, contract) from + the DB at the start of each test: switching origin between tests reloads + the spec bundle, so module-level variables do NOT survive across tests. + +## Extending + +Deep module flows (booking wizard → staff approval → scheduling → billing) +belong in `flows/`. Pattern: arrange via API/`db:query`, act through the UI of +one app, assert through the UI of the other + a `db:query` cross-check. Prefer +adding `data-testid` attributes to app code over brittle text selectors. diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts new file mode 100644 index 000000000..a2acffe24 --- /dev/null +++ b/e2e/freight/cypress.config.ts @@ -0,0 +1,83 @@ +import { defineConfig } from "cypress"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "pg"; + +/** + * Freight e2e suite. Three origins: + * backoffice http://localhost:5383 (baseUrl — most specs live here) + * portal http://localhost:5373 (env.portalUrl; portal specs cy.visit it, + * cross-app flows reach it via cy.origin) + * api http://localhost:3101 (env.apiUrl; cy.request only) + * + * All URLs are host-published ports from docker-compose.e2e.yaml. The cypress + * container in that compose file runs with network_mode: host, so the same + * localhost URLs work identically for `cypress open` on the host and for the + * containerized headless run. + */ +export default defineConfig({ + e2e: { + baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383", + specPattern: "cypress/e2e/**/*.cy.ts", + supportFile: "cypress/support/e2e.ts", + video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true", + screenshotOnRunFailure: true, + viewportWidth: 1440, + viewportHeight: 900, + defaultCommandTimeout: 10000, + requestTimeout: 15000, + retries: { runMode: 1, openMode: 0 }, + env: { + apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101", + portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373", + backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383", + // Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml. + defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria", + // Demo portal users: hardcoded in DemoUsersSeeder. + demoPassword: "12345678", + }, + setupNodeEvents(on) { + const dbUrl = + process.env.E2E_DB_URL ?? + "postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e"; + + on("task", { + /** Run an arbitrary SQL statement against the ephemeral e2e database. */ + async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) { + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + const result = await client.query(sql, params as never[]); + return { rowCount: result.rowCount, rows: result.rows }; + } finally { + await client.end(); + } + }, + + /** + * Seed the test users (staff + demo) directly in SQL. The API's + * user seeders are disabled in app code, so the fixture replicates + * their output. Idempotent — safe to run before every spec file. + */ + async "db:seedUsers"() { + // cwd = the e2e/freight project root when Cypress runs. + // seed-company.sql depends on rows from seed-users.sql — keep order. + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + for (const file of ["seed-users.sql", "seed-company.sql"]) { + const sql = readFileSync( + join(process.cwd(), "cypress", "fixtures", file), + "utf8", + ); + await client.query(sql); + } + return true; + } finally { + await client.end(); + } + }, + }); + }, + }, +}); diff --git a/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts new file mode 100644 index 000000000..c183a11df --- /dev/null +++ b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts @@ -0,0 +1,79 @@ +/** + * API contract smoke — no browser, pure cy.request against freight-api. + * Verifies the containerized stack booted: migrations ran, seeders ran, + * auth issues tokens. + */ +const api = () => Cypress.env("apiUrl") as string; + +describe("freight-api: health + auth contract", () => { + it("GET /api/health responds", () => { + cy.request(`${api()}/api/health`).its("status").should("eq", 200); + }); + + it("rejects bad credentials", () => { + cy.request({ + method: "POST", + url: `${api()}/api/auth/login`, + body: { email: "nobody@edr.local", password: "wrong-password" }, + failOnStatusCode: false, + }) + .its("status") + .should("be.oneOf", [400, 401, 404]); + }); + + it("logs in every seeded staff user", () => { + cy.fixture("users.json").then((users) => { + Object.values<{ email: string }>(users.staff).forEach(({ email }) => { + cy.apiLogin(email); + }); + }); + }); + + it("staff token can read /api/me", () => { + cy.apiLogin("ceo@edr.local").then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }).then((response) => { + expect(response.status).to.eq(200); + }); + }); + }); + + it("refresh-token rotates the session", () => { + cy.apiLogin("chief@edr.local").then(({ refreshToken }) => { + cy.request("POST", `${api()}/api/auth/refresh-token`, { refreshToken }) + .its("body.token") + .should("be.a", "string"); + }); + }); + + it("demo portal users are seeded", () => { + // DemoUsersSeeder hardcodes this password (staff users use DEFAULT_PASSWORD) + cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")); + cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword")); + }); +}); + +describe("freight-api: seeded database", () => { + it("migrations table is populated", () => { + cy.task<{ rowCount: number }>("db:query", { + sql: "select count(*)::int as count from migrations", + }).then(({ rows }: any) => { + expect(rows[0].count).to.be.greaterThan(100); + }); + }); + + it("staff users exist with credentials", () => { + cy.task("db:query", { + sql: `select u.email from iam.users u + join iam.user_credentials c on c.user_id = u.id + where u.email like '%@edr.local' order by u.email`, + }).then(({ rows }: any) => { + const emails = rows.map((row: { email: string }) => row.email); + expect(emails).to.include.members(["ceo@edr.local", "linestaff@edr.local"]); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/backoffice/login.cy.ts b/e2e/freight/cypress/e2e/backoffice/login.cy.ts new file mode 100644 index 000000000..0a729bdae --- /dev/null +++ b/e2e/freight/cypress/e2e/backoffice/login.cy.ts @@ -0,0 +1,41 @@ +/** + * The one UI-driven login spec for backoffice — every other spec uses the + * programmatic cy.loginBackoffice() session. + * Login form: apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx + * (Mantine inputs, matched by placeholder). + */ +describe("backoffice: UI login", () => { + it("redirects unauthenticated users to /auth", () => { + cy.clearCookies(); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("eq", "/auth"); + }); + + it("logs in via the form and lands on the dashboard", () => { + cy.clearCookies(); + cy.visit("/auth"); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local"); + cy.get('input[placeholder="Enter your password"]').type( + Cypress.env("defaultPassword"), + { log: false }, + ); + cy.get('button[type="submit"]').click(); + + cy.location("pathname", { timeout: 20000 }).should("match", /^\/dashboard/); + cy.getCookie("auth-token").should("exist"); + cy.getCookie("refresh-token").should("exist"); + }); + + it("shows an error for wrong credentials and stays on /auth", () => { + cy.clearCookies(); + cy.visit("/auth"); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local"); + cy.get('input[placeholder="Enter your password"]').type("definitely-wrong"); + cy.get('button[type="submit"]').click(); + + cy.location("pathname").should("eq", "/auth"); + cy.getCookie("auth-token").should("not.exist"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts b/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts new file mode 100644 index 000000000..db3086e71 --- /dev/null +++ b/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts @@ -0,0 +1,49 @@ +/** + * Route-level smoke over the backoffice shell: each key module page loads + * without bouncing back to /auth and without an unhandled crash. Deep + * per-module behavior belongs in dedicated specs — this catches the broad + * "page is broken / route is dead / guard rejects seeded role" class. + * Routes from apps/edr-freight-web/backoffice/src/App.tsx. + */ +const ROUTES = [ + "/dashboard/overview", + "/dashboard/booking-requests", + "/dashboard/customers", + "/dashboard/invoices", + "/dashboard/contract-requests", + "/dashboard/shipment-requests", + "/dashboard/profile", +]; + +describe("backoffice: route smoke (ceo)", () => { + beforeEach(() => { + cy.loginBackoffice("ceo@edr.local"); + }); + + ROUTES.forEach((route) => { + it(`renders ${route}`, () => { + cy.visit(route); + cy.location("pathname").should("not.eq", "/auth"); + cy.location("pathname").should("contain", "/dashboard"); + cy.get("#root").should("not.be.empty"); + }); + }); +}); + +describe("backoffice: role-based access", () => { + it("line staff can reach the dashboard shell", () => { + cy.loginBackoffice("linestaff@edr.local"); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("not.eq", "/auth"); + cy.get("#root").should("not.be.empty"); + }); + + it("operations officer can reach the dashboard shell", () => { + cy.loginBackoffice("operation@edr.local"); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("not.eq", "/auth"); + cy.get("#root").should("not.be.empty"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts new file mode 100644 index 000000000..e78fe1905 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts @@ -0,0 +1,216 @@ +/** + * Contract creation → finalization, spanning portal + backoffice: + * + * 1. portal (user@gmail.com, company seeded active by seed-company.sql): + * wizard → GENERAL / Import / Container / 20ft → submit + approve quote + * 2. backoffice marketer: "Accept for approval" (validity) + approve the + * LINE_STAFF step + * 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY + * 4. portal customer: scroll contract, agree, draw signature, OTP-sign + * → SIGNED_CUSTOMER + * 5. backoffice marketer: counter-sign as staff → GENERAL contract goes + * CONTRACT_ACTIVE (per-booking clearance, no contract-level gate) + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql + +/** + * The journey's contract = the seeded company's latest contract. Each test + * resolves it from the DB instead of sharing module state — tests stay + * independently runnable against the current DB state. + */ +function dbContract() { + return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>( + "db:query", + { + sql: `SELECT ct.id, ct.reference, ct.status + FROM freight.contracts ct + JOIN freight.companies c ON c.id = ct.company_id + WHERE c.tin = $1 + ORDER BY ct.created_at DESC LIMIT 1`, + params: [companyTin], + }, + ); +} + +function withContract(fn: (c: { id: string; reference: string; status: string }) => void) { + dbContract().then(({ rows }) => { + expect(rows, "latest contract for the seeded company").to.have.length(1); + fn(rows[0]); + }); +} + +function expectStatus(expected: string) { + dbContract().then(({ rows }) => { + expect(rows[0]?.status, `contract status`).to.eq(expected); + }); +} + +describe("contract lifecycle: creation to finalization", { retries: 0 }, () => { + it("customer creates and submits a GENERAL import container contract", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + // Step 0 — Setup. + cy.mantineSelect(/^Operation Type/, /^Import$/); + cy.mantineSelect(/^Contract Kind/, "General Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("Rail Transport Only", { timeout: 15000 }).click(); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + // Step 1 — Cargo & Route. + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type( + "E2E electronics shipment scope", + ); + cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal"); + cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port"); + cy.contains("button", "Continue").click({ force: true }); + + // Step 2 — Review & Submit → quotation modal. + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should( + "be.visible", + ); + cy.contains("button", "Approve & submit").click(); + + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + cy.contains("Submitted", { timeout: 15000 }).should("be.visible"); + + dbContract().then(({ rows }) => { + expect(rows, "contract row").to.have.length(1); + expect(rows[0].status).to.eq("SUBMITTED"); + expect(rows[0].reference).to.match(/^CTR-/); + }); + }); + + it("marketer accepts the submission and approves the LINE_STAFF step", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Accept for approval", { timeout: 20000 }).click(); + // Validity defaults to the first configured option in the accept modal. + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + + // Approval chain instantiated: LINE_STAFF → DIRECTOR. Approve step 1. + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + expectStatus("PENDING_APPROVAL"); + }); + + it("director approves the final step — contract PDF becomes ready", () => { + cy.loginBackoffice("director@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + + // Final approval renders the contract PDF synchronously → CONTRACT_READY. + // The approval-chain card unmounts once the contract leaves approval, so + // assert on the signing CTA that replaces it. + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + + expectStatus("CONTRACT_READY"); + }); + + it("customer signs the contract with OTP", () => { + cy.loginPortal(customer); + withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`)); + + // Scroll the contract iframe to the bottom so the consent bar unlocks. + // Retried because the iframe can re-render (query refetch) after a scroll. + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then( + ($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + // documentElement can be null while the srcDoc is (re)parsing — + // skip this round and let the retry pick it up. + const el = + win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }, + ); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + + // Signature modal: name + drawn signature. + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + + // OTP modal — code goes to the signer's registered contacts (email only + // for the seeded demo user); read it from the DB. + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + + cy.contains("Your signature has been recorded", { timeout: 30000 }).should( + "be.visible", + ); + expectStatus("SIGNED_CUSTOMER"); + }); + + it("staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`)); + + cy.contains("button", /^Sign as staff$|^Approve & sign$/, { + timeout: 30000, + }).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); + }); + cy.drawSignature(); + // Scoped to the modal — the toolbar behind it has its own "Approve & sign". + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); + + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + + // GENERAL → clearance runs per booking, contract goes straight active. + expectStatus("CONTRACT_ACTIVE"); + + // Both signatures recorded. + withContract((c) => { + cy.task<{ rows: Array<{ role: string }> }>("db:query", { + sql: `SELECT s.role FROM freight.contract_signatures s + WHERE s.contract_id = $1 ORDER BY s.role`, + params: [c.id], + }).then(({ rows }) => { + expect(rows.map((r) => r.role)).to.include.members(["CUSTOMER", "STAFF"]); + }); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/cross-app.cy.ts b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts new file mode 100644 index 000000000..fe60170ba --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts @@ -0,0 +1,69 @@ +/** + * Cross-app flow: same business objects seen from both directions — + * customer (portal, port 5373) and staff (backoffice, port 5383 = baseUrl). + * Different ports = different origins, so portal steps inside a test that + * also touches backoffice run inside cy.origin(). + * + * Cookie caveat: cookies ignore ports, so both apps share the localhost + * cookie jar. Always call the matching login command immediately before + * switching apps — cy.session restores the right cookie snapshot. + * + * This spec is the template for full journeys (booking → approval → + * scheduling → billing). It verifies both sides of the fence against + * seeded data via UI + API cross-checks. + */ +const portal = () => Cypress.env("portalUrl") as string; +const api = () => Cypress.env("apiUrl") as string; + +describe("flow: customer and staff see the same world", () => { + it("staff views booking requests, customer views bookings", () => { + // Staff side on the primary origin (baseUrl) first — the first origin a + // test visits becomes primary; every other origin needs cy.origin(). + cy.loginBackoffice("ceo@edr.local"); + cy.visit("/dashboard/booking-requests"); + cy.location("pathname").should("eq", "/dashboard/booking-requests"); + cy.get("#root").should("not.be.empty"); + + // Customer side — switch session first, then enter the portal origin. + cy.loginPortal("user@gmail.com"); + cy.origin(portal(), () => { + cy.visit("/bookings"); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); + + it("staff and customer both resolve their own /api/me identity", () => { + cy.apiLogin("ceo@edr.local").then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("eq", 200); + }); + cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")).then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("eq", 200); + }); + }); + + it("cy.origin: staff dashboard then portal in a single test", () => { + cy.loginBackoffice("ceo@edr.local"); + cy.visit("/dashboard/overview"); + cy.get("#root").should("not.be.empty"); + + cy.loginPortal("user@gmail.com"); + cy.origin(Cypress.env("portalUrl") as string, () => { + cy.visit("/portal"); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts new file mode 100644 index 000000000..ed6df900b --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts @@ -0,0 +1,203 @@ +/** + * Full customer onboarding journey, both apps: + * + * 1. portal — /signup form → OTP (read from DB, delivery is off in e2e) + * → account created → onboarding wizard (nationality/role → + * company → personnel → contact → PoA → documents incl. the + * per-role business license) → "Submit for review" + * 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the + * importer profile on /dashboard/customers/:id + * 3. portal — the new customer is active: contract wizard reachable + * + * Tests are sequential steps of ONE journey (fresh unique user per run), so + * retries are disabled — a mid-journey retry would replay a non-idempotent + * step against already-advanced state. + * + * NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383) + * reloads the spec bundle and resets module state — later tests resolve the + * journey's user/company from the DB instead of module variables. + */ + +const stamp = Date.now(); +const email = `e2e.onboard.${stamp}@example.com`; +// Ethiopian mobile: 9 + 8 digits, unique per run. +const phoneNational = `9${String(stamp).slice(-8)}`; +const signupPassword = "Password@e2e1"; +const companyName = `E2E Onboard Co ${stamp}`; +const tin = String(stamp).slice(-10).padStart(10, "1"); +const vat = String(stamp + 1).slice(-10).padStart(10, "2"); +const fan = String(stamp).slice(-13).padStart(16, "3"); + +const portal = () => Cypress.env("portalUrl") as string; + +/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */ +function latestOnboardJourney() { + return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", { + sql: `SELECT c.name, u.email + FROM freight.companies c + JOIN freight.external_profiles ep ON ep.company_id = c.id + JOIN iam.users u ON u.id = ep.user_id + WHERE u.email LIKE 'e2e.onboard.%' + ORDER BY c.created_at DESC LIMIT 1`, + }); +} + +/** Fill a labelled Mantine input (label[for] → input id). */ +function fill(label: string | RegExp, value: string) { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** The wizard's phone inputs (react-phone-number-input, type=tel). */ +function fillPhone(index: number, national: string) { + cy.get('.mantine-Modal-content input[type="tel"]') + .eq(index) + .clear({ force: true }) + .type(national, { force: true }); +} + +describe("customer onboarding journey", { retries: 0 }, () => { + it("signs up with OTP and completes the onboarding wizard", () => { + // The eTrade TIN lookup 400s in e2e (external service unreachable). The + // form handles it ("fill in the details manually") but axios also throws + // an uncaught rejection — ignore just that one. + cy.on("uncaught:exception", (err) => + err.message.includes("Request failed with status code 400") ? false : true, + ); + cy.visit(`${portal()}/signup`); + + fill(/^First name/, "Onboard"); + fill(/^Last name/, "Tester"); + fill(/^Email/, email); + cy.get('input[type="tel"]').first().type(phoneNational, { force: true }); + fill(/^Password/, signupPassword); + fill(/^Confirm password/, signupPassword); + cy.contains("button", "Continue").click(); + + // OTP stage — the code is generated + stored even though delivery is off. + cy.contains("Verify", { timeout: 15000 }).should("be.visible"); + cy.getOtp(email).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & create account").click(); + + // Signed in → /portal → wizard auto-opens on the nationality/role step. + cy.location("pathname", { timeout: 20000 }).should("eq", "/portal"); + cy.contains("Where is your company registered?", { timeout: 15000 }).should( + "be.visible", + ); + cy.contains("button", "Ethiopian Company").click(); + cy.contains("button", "Importer").click(); + cy.get(".mantine-Modal-content").contains("button", "Continue").click(); + + // Company step. TIN first — the eTrade auto-lookup fails in e2e (no + // external network) and the form allows manual entry. + cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin); + fill(/^Company Name/, companyName); + fill(/^Company Email/, `ops.${stamp}@example.com`); + fillPhone(0, "911234567"); + fill(/^Location/, "Addis Ababa, Ethiopia"); + fill(/^VAT Number/, vat); + cy.get('input[placeholder="1234567890123456"]').type(fan); + cy.mantineSelect(/^Region/, "Addis Ababa"); + fill(/^Zone/, "Zone 1"); + fill(/^Woreda/, "Woreda 1"); + fill(/^Kebele/, "Kebele 1"); + fill(/^House No/, "123"); + cy.get(".mantine-Modal-content").contains("button", "Continue").click(); + + // Personnel (general manager). + fill(/^Name/, "General Manager"); + fill(/^Email/, `gm.${stamp}@example.com`); + fillPhone(0, "911234568"); + cy.get(".mantine-Modal-content").contains("button", "Continue").click(); + + // Contact person. + fill(/^Name/, "Contact Person"); + fillPhone(0, "911234569"); + cy.get(".mantine-Modal-content").contains("button", "Continue").click(); + + // PoA — optional for an importer. + cy.get(".mantine-Modal-content").contains("button", "Continue").click(); + + // Documents: no company docs are configured in e2e, but every role needs + // a business license. + cy.contains("Business license", { timeout: 15000 }).should("be.visible"); + cy.get('.mantine-Modal-content input[type="file"]') + .first() + .selectFile("cypress/fixtures/docs/license.pdf", { force: true }); + cy.get(".mantine-Modal-content") + .contains("button", "Submit for review") + .click(); + + cy.contains("You're all set", { timeout: 30000 }).should("be.visible"); + + // DB cross-check: submitted, awaiting approval. + cy.task<{ rows: Array<{ status: string; onboarding_completed: boolean }> }>( + "db:query", + { + sql: `SELECT c.status, ep.onboarding_completed + FROM freight.companies c + JOIN freight.external_profiles ep ON ep.company_id = c.id + JOIN iam.users u ON u.id = ep.user_id + WHERE u.email = $1`, + params: [email], + }, + ).then(({ rows }) => { + expect(rows, "company row").to.have.length(1); + expect(rows[0].status).to.eq("pending"); + expect(rows[0].onboarding_completed).to.eq(true); + }); + }); + + it("backoffice staff approves the submitted importer profile", () => { + cy.loginBackoffice("chief@edr.local"); + cy.visit("/dashboard/customers"); + + latestOnboardJourney().then(({ rows }) => { + expect(rows, "onboarded company").to.have.length(1); + const company = rows[0].name; + + cy.get('input[placeholder*="Search by company"]').type(company); + cy.contains(company, { timeout: 15000 }).click(); + + // Role profiles table → approve the pending importer profile. Once + // active, the row's action flips to "Suspend". + cy.contains("button", "Approve", { timeout: 15000 }).click(); + cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible"); + + cy.task<{ rows: Array<{ status: string; reference: string | null; company_status: string }> }>( + "db:query", + { + sql: `SELECT p.status, p.reference, c.status AS company_status + FROM freight.company_profiles p + JOIN freight.companies c ON c.id = p.company_id + WHERE c.name = $1 AND p.type = 'importer'`, + params: [company], + }, + ).then(({ rows: profiles }) => { + expect(profiles, "importer profile").to.have.length(1); + expect(profiles[0].status).to.eq("active"); + expect(profiles[0].reference, "minted reference").to.be.a("string").and + .not.be.empty; + expect(profiles[0].company_status).to.eq("active"); + }); + }); + }); + + it("the approved customer can reach the contract wizard", () => { + latestOnboardJourney().then(({ rows }) => { + cy.loginPortal(rows[0].email, signupPassword); + }); + cy.visitPortal("/contracts/new"); + + // No "Awaiting Approval" gate — the wizard's first step renders. + cy.contains("label", "Operation Type", { timeout: 15000 }).should( + "be.visible", + ); + cy.contains("Awaiting Approval").should("not.exist"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/portal/login.cy.ts b/e2e/freight/cypress/e2e/portal/login.cy.ts new file mode 100644 index 000000000..55ef4bc21 --- /dev/null +++ b/e2e/freight/cypress/e2e/portal/login.cy.ts @@ -0,0 +1,36 @@ +/** + * UI login for the customer portal (seeded demo user). + * Form: apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx. + * Portal is a different origin (port 5373), so specs visit it via absolute + * URL — each test here stays on that single origin, no cy.origin needed. + */ +const portal = () => Cypress.env("portalUrl") as string; + +describe("portal: UI login", () => { + it("logs in via the form", () => { + cy.clearCookies(); + cy.visit(`${portal()}/login`); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com"); + cy.get('input[placeholder="Enter your password"]').type( + Cypress.env("demoPassword"), + { log: false }, + ); + cy.get('button[type="submit"]').click(); + + cy.location("pathname", { timeout: 20000 }).should("not.eq", "/login"); + cy.getCookie("auth-token").should("exist"); + }); + + it("rejects wrong credentials", () => { + cy.clearCookies(); + cy.visit(`${portal()}/login`); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com"); + cy.get('input[placeholder="Enter your password"]').type("definitely-wrong"); + cy.get('button[type="submit"]').click(); + + cy.location("pathname").should("eq", "/login"); + cy.getCookie("auth-token").should("not.exist"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/portal/smoke.cy.ts b/e2e/freight/cypress/e2e/portal/smoke.cy.ts new file mode 100644 index 000000000..5372d7359 --- /dev/null +++ b/e2e/freight/cypress/e2e/portal/smoke.cy.ts @@ -0,0 +1,29 @@ +/** + * Route-level smoke over the customer portal. + * Routes from apps/edr-freight-web/portal/src/App.tsx. + */ +const portal = () => Cypress.env("portalUrl") as string; + +const ROUTES = ["/portal", "/bookings", "/contracts", "/billing", "/tracking"]; + +describe("portal: route smoke (demo customer)", () => { + beforeEach(() => { + cy.loginPortal("user@gmail.com"); + }); + + ROUTES.forEach((route) => { + it(`renders ${route}`, () => { + cy.visit(`${portal()}${route}`); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); + + it("new booking wizard opens", () => { + cy.visit(`${portal()}/bookings/new`); + cy.location("pathname").should("eq", "/bookings/new"); + cy.get("#root").should("not.be.empty"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/fixtures/docs/license.pdf b/e2e/freight/cypress/fixtures/docs/license.pdf new file mode 100644 index 000000000..8a4f1376d --- /dev/null +++ b/e2e/freight/cypress/fixtures/docs/license.pdf @@ -0,0 +1,11 @@ +%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>endobj +xref +0 4 +0000000000 65535 f +trailer<> +startxref +0 +%%EOF diff --git a/e2e/freight/cypress/fixtures/seed-company.sql b/e2e/freight/cypress/fixtures/seed-company.sql new file mode 100644 index 000000000..73b01bee1 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-company.sql @@ -0,0 +1,51 @@ +-- Arrange-data for the contract lifecycle specs, applied after seed-users.sql. +-- Idempotent. Two things the app cannot provide without manual steps: +-- +-- 1. chief gets `edr_freight_app:admin` (customer-profile approval is +-- FreightAdmin-guarded and no seeded position carries it). +-- 2. user@gmail.com gets an ACTIVE company + approved importer profile so the +-- contract wizard is reachable without first running the onboarding journey. + +-- 1. chief → edr_freight_app:admin +INSERT INTO iam.position_permissions (id, position_id, permission_id) +SELECT gen_random_uuid(), p.id, perm.id +FROM iam.positions p +JOIN iam.permissions perm ON perm.key = 'edr_freight_app:admin' +WHERE p.key = 'chief' + AND NOT EXISTS ( + SELECT 1 FROM iam.position_permissions pp + WHERE pp.position_id = p.id AND pp.permission_id = perm.id + ); + +-- 2a. Active customer company (TIN is the idempotency key). +INSERT INTO freight.companies + (id, name, type, status, tin, fan_number, country, address, phone, email, + nationality, kind, attributes) +SELECT gen_random_uuid(), 'E2E Logistics PLC', 'customer', 'active', + '0102030405', '1234567890123456', 'Ethiopia', 'Addis Ababa, Ethiopia', + '+251911000001', 'ops@e2e-logistics.test', 'ethiopian', 'commercial', + '{"contactPersonName":"Test Contact","contactPersonPhone":"+251911000002","generalManagerName":"Test GM","generalManagerEmail":"gm@e2e-logistics.test","generalManagerPhone":"+251911000003"}'::jsonb +WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030405'); + +-- 2b. Approved importer profile (reference normally minted on approval). +INSERT INTO freight.company_profiles (id, company_id, type, status, reference) +SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-E2E-0001' +FROM freight.companies c +WHERE c.tin = '0102030405' + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles p + WHERE p.company_id = c.id AND p.type = 'importer' + ); + +-- 2c. Link the demo portal user to the company, onboarding already done. +INSERT INTO freight.external_profiles + (id, user_id, company_id, first_name, last_name, is_primary_contact, + active_profile_type, onboarding_step, onboarding_completed) +SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true, + 'importer', 'done', true +FROM iam.users u +JOIN freight.companies c ON c.tin = '0102030405' +WHERE u.email = 'user@gmail.com' + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id + ); diff --git a/e2e/freight/cypress/fixtures/seed-users.sql b/e2e/freight/cypress/fixtures/seed-users.sql new file mode 100644 index 000000000..5f42e3d5f --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-users.sql @@ -0,0 +1,137 @@ +-- Test users for the freight e2e stack — replicates what the (disabled) +-- FreightStaffUsersSeeder + DemoUsersSeeder would write, without touching +-- API code. Idempotent: every insert is guarded by WHERE NOT EXISTS. +-- +-- Prerequisites (created by the API's always-on boot seeders): +-- iam.organizations key='edr_freight', iam.units key='edr_freight_app', +-- iam.positions keys ceo/chief/director/marketer/operation/ethiopian_gl/djibouti_gl. +-- +-- Passwords are pre-hashed (argon2id): +-- staff (@edr.local) → password@tria +-- demo (gmail.com) → 12345678 + +-- ── Demo organization ──────────────────────────────────────────────────────── +insert into iam.organizations (id, name, key, is_super_admin, is_government_organization, status) +select gen_random_uuid(), '{"en":"Demo IAM"}'::jsonb, 'demo_iam', false, true, 'Active' +where not exists (select 1 from iam.organizations where key = 'demo_iam'); + +-- ── Roles ──────────────────────────────────────────────────────────────────── +insert into iam.roles (id, key, name) +select gen_random_uuid(), v.key, jsonb_build_object('en', v.name) +from (values + ('edr_line_staff', 'edr_line_staff'), + ('edr_org_manager', 'edr_org_manager'), + ('edr_director', 'edr_director'), + ('edr_ceo', 'edr_ceo'), + ('edr_marketing', 'edr_marketing'), + ('edr_operations_officer', 'edr_operations_officer'), + ('edr_gl_ethiopia', 'edr_gl_ethiopia'), + ('edr_gl_djibouti', 'edr_gl_djibouti'), + ('demo_user1', 'Demo User1'), + ('demo_user2', 'Demo User2') +) v(key, name) +where not exists (select 1 from iam.roles r where r.key = v.key); + +-- ── Demo permissions + role grants ─────────────────────────────────────────── +insert into iam.permissions (id, key, name) +select gen_random_uuid(), v.key, jsonb_build_object('en', v.name) +from (values + ('can:demo:user1', 'Can access demo user1'), + ('can:demo:user2', 'Can access demo user2') +) v(key, name) +where not exists (select 1 from iam.permissions p where p.key = v.key); + +insert into iam.role_permissions (id, role_id, permission_id) +select gen_random_uuid(), r.id, p.id +from (values ('demo_user1', 'can:demo:user1'), ('demo_user2', 'can:demo:user2')) v(role_key, perm_key) +join iam.roles r on r.key = v.role_key +join iam.permissions p on p.key = v.perm_key +where not exists ( + select 1 from iam.role_permissions rp where rp.role_id = r.id and rp.permission_id = p.id +); + +-- ── Users ──────────────────────────────────────────────────────────────────── +insert into iam.users (id, email, username, name, status, is_active, has_set_password, user_type) +select gen_random_uuid(), v.email, v.username, jsonb_build_object('en', v.display), + 'accepted', true, true, 'employee' +from (values + ('linestaff@edr.local', 'linestaff', 'linestaff'), + ('chief@edr.local', 'chief', 'chief'), + ('director@edr.local', 'director', 'director'), + ('ceo@edr.local', 'ceo', 'ceo'), + ('marketer@edr.local', 'marketer', 'marketer'), + ('operation@edr.local', 'operation', 'operation'), + ('gl-et@edr.local', 'gl_et', 'gl_et'), + ('gl-dj@edr.local', 'gl_dj', 'gl_dj'), + ('user@gmail.com', 'user', 'Demo User 1'), + ('user2@gmail.com', 'user2', 'Demo User 2') +) v(email, username, display) +where not exists (select 1 from iam.users u where u.email = v.email); + +-- ── Credentials ────────────────────────────────────────────────────────────── +insert into iam.user_credentials (id, user_id, password, is_active) +select gen_random_uuid(), u.id, + case when u.email like '%@edr.local' + then '$argon2id$v=19$m=65536,t=3,p=4$JFEcHu4Kp55fsrVDPbHDPg$0NfnGzaE39T/qdmzte73oCkohC0Ri+f8DcrvAF4kyH4' -- password@tria + else '$argon2id$v=19$m=65536,t=3,p=4$aBwVFf7I74pqSJqe9cBoig$gCIKa+6dCAb2X86G+0IjgPtil127cx6A6mwhvLj00Bw' -- 12345678 + end, + true +from iam.users u +where (u.email like '%@edr.local' or u.email in ('user@gmail.com', 'user2@gmail.com')) + and not exists (select 1 from iam.user_credentials c where c.user_id = u.id); + +-- ── User → role (staff under edr_freight, demo under demo_iam) ────────────── +insert into iam.user_roles (id, user_id, role_id, organization_id) +select gen_random_uuid(), u.id, r.id, o.id +from (values + ('linestaff@edr.local', 'edr_line_staff', 'edr_freight'), + ('chief@edr.local', 'edr_org_manager', 'edr_freight'), + ('director@edr.local', 'edr_director', 'edr_freight'), + ('ceo@edr.local', 'edr_ceo', 'edr_freight'), + ('marketer@edr.local', 'edr_marketing', 'edr_freight'), + ('operation@edr.local', 'edr_operations_officer', 'edr_freight'), + ('gl-et@edr.local', 'edr_gl_ethiopia', 'edr_freight'), + ('gl-dj@edr.local', 'edr_gl_djibouti', 'edr_freight'), + ('user@gmail.com', 'demo_user1', 'demo_iam'), + ('user2@gmail.com', 'demo_user2', 'demo_iam') +) v(email, role_key, org_key) +join iam.users u on u.email = v.email +join iam.roles r on r.key = v.role_key +join iam.organizations o on o.key = v.org_key +where not exists (select 1 from iam.user_roles ur where ur.user_id = u.id and ur.role_id = r.id); + +-- ── Staff employees + position assignment (drives permissions) ─────────────── +insert into iam.employees (id, is_current, status, name, organization_id, unit_id, user_id) +select gen_random_uuid(), true, 'pending', u.name, o.id, un.id, u.id +from iam.users u +join iam.organizations o on o.key = 'edr_freight' +join iam.units un on un.key = 'edr_freight_app' and un.organization_id = o.id +where u.email like '%@edr.local' + and not exists (select 1 from iam.employees e where e.user_id = u.id); + +-- start_date must be set: the login query filters positions on +-- start_date <= NOW(), and a NULL start_date silently drops the position +-- (and with it every permission) from the JWT. +insert into iam.employee_positions (id, is_delegate, is_current, status, start_date, unit_id, employee_id, position_id) +select gen_random_uuid(), false, true, 'APPROVED', now() - interval '1 day', un.id, e.id, p.id +from (values + ('linestaff@edr.local', 'operation'), + ('chief@edr.local', 'chief'), + ('director@edr.local', 'director'), + ('ceo@edr.local', 'ceo'), + ('marketer@edr.local', 'marketer'), + ('operation@edr.local', 'operation'), + ('gl-et@edr.local', 'ethiopian_gl'), + ('gl-dj@edr.local', 'djibouti_gl') +) v(email, position_key) +join iam.users u on u.email = v.email +join iam.employees e on e.user_id = u.id +join iam.units un on un.key = 'edr_freight_app' +join iam.positions p on p.key = v.position_key and p.unit_id = un.id +where not exists ( + select 1 from iam.employee_positions ep where ep.employee_id = e.id and ep.position_id = p.id +); + +-- Backfill for rows created before start_date was included above. +update iam.employee_positions set start_date = now() - interval '1 day' +where start_date is null; diff --git a/e2e/freight/cypress/fixtures/users.json b/e2e/freight/cypress/fixtures/users.json new file mode 100644 index 000000000..88c56149f --- /dev/null +++ b/e2e/freight/cypress/fixtures/users.json @@ -0,0 +1,16 @@ +{ + "staff": { + "lineStaff": { "email": "linestaff@edr.local", "role": "edr_line_staff" }, + "chief": { "email": "chief@edr.local", "role": "edr_org_manager" }, + "director": { "email": "director@edr.local", "role": "edr_director" }, + "ceo": { "email": "ceo@edr.local", "role": "edr_ceo" }, + "marketer": { "email": "marketer@edr.local", "role": "edr_marketing" }, + "operation": { "email": "operation@edr.local", "role": "edr_operations_officer" }, + "glEthiopia": { "email": "gl-et@edr.local", "role": "edr_gl_ethiopia" }, + "glDjibouti": { "email": "gl-dj@edr.local", "role": "edr_gl_djibouti" } + }, + "customers": { + "demo1": { "email": "user@gmail.com" }, + "demo2": { "email": "user2@gmail.com" } + } +} diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts new file mode 100644 index 000000000..6a958d5be --- /dev/null +++ b/e2e/freight/cypress/support/commands.ts @@ -0,0 +1,176 @@ +/** + * Auth model (see apps/edr-freight-api + freight web apps): + * - POST {api}/api/auth/login { email, password } + * → flattened body { success, token, refreshToken } (response interceptor + * flattens /api/auth responses — no .data nesting). + * - Both web apps read cookies `auth-token` / `refresh-token` and attach + * `Authorization: Bearer `. + * - Cookies are port-agnostic on localhost, so portal and backoffice share + * one cookie jar. cy.session snapshots/restores cookies per session id, + * which keeps staff and customer sessions from clobbering each other — + * but inside a single test, switching apps requires re-invoking the + * matching login command first (see flows specs). + */ + +export interface LoginBody { + success: boolean; + token: string; + refreshToken: string; +} + +const apiUrl = () => Cypress.env("apiUrl") as string; +const password = () => Cypress.env("defaultPassword") as string; + +function apiLogin(email: string, pass?: string): Cypress.Chainable { + return cy + .request("POST", `${apiUrl()}/api/auth/login`, { + email, + password: pass ?? password(), + }) + .then((response) => { + expect(response.status).to.eq(201); + expect(response.body.token, "login token").to.be.a("string"); + return cy.wrap(response.body, { log: false }); + }); +} + +function sessionFor(app: "backoffice" | "portal", email: string, pass?: string) { + cy.session( + [app, email], + () => { + apiLogin(email, pass).then(({ token, refreshToken }) => { + cy.setCookie("auth-token", token); + cy.setCookie("refresh-token", refreshToken); + }); + }, + { + cacheAcrossSpecs: true, + validate() { + cy.getCookie("auth-token").then((cookie) => { + expect(cookie, "auth-token cookie").to.exist; + cy.request({ + url: `${apiUrl()}/api/me`, + headers: { Authorization: `Bearer ${cookie!.value}` }, + }) + .its("status") + .should("eq", 200); + }); + }, + }, + ); +} + +Cypress.Commands.add("apiLogin", (email: string, pass?: string) => apiLogin(email, pass)); + +Cypress.Commands.add("loginBackoffice", (email = "ceo@edr.local", pass?: string) => { + sessionFor("backoffice", email, pass); +}); + +Cypress.Commands.add("loginPortal", (email = "user@gmail.com", pass?: string) => { + // Demo portal users are seeded with a hardcoded password (DemoUsersSeeder), + // unlike staff users which use DEFAULT_PASSWORD. + sessionFor("portal", email, pass ?? (Cypress.env("demoPassword") as string)); +}); + +Cypress.Commands.add("visitPortal", (path = "/") => { + cy.visit(`${Cypress.env("portalUrl")}${path}`); +}); + +/** + * Read the latest OTP the API generated for a contact. SMS/email delivery is + * disabled in e2e (RABBITMQ_ENABLED=false) but the code is still stored in + * freight.otp_verifications — keyed by normalized email (lowercased) or E.164 + * phone. Polls because the row is written async to the UI action. + */ +Cypress.Commands.add("getOtp", (target: string) => { + const read = (attempt: number): Cypress.Chainable => + cy + .task<{ rows: Array<{ otp: string }> }>( + "db:query", + { + sql: `SELECT otp FROM freight.otp_verifications + WHERE email = $1 OR phone = $1 + ORDER BY updated_at DESC LIMIT 1`, + params: [target], + }, + { log: false }, + ) + .then((res) => { + if (res.rows.length > 0) return cy.wrap(res.rows[0].otp, { log: false }); + expect(attempt, `OTP row for ${target}`).to.be.lessThan(20); + return cy.wait(500, { log: false }).then(() => read(attempt + 1)); + }); + return read(0); +}); + +/** Open a Mantine - Drop tickets.json here or click to browse - -

Accepts a JSON array of tickets or an object with a tickets key.

-
- - - -
- - -
-
-
- - - - - - - - - - - - - - - - - - -
#Ticket No.Booking RefPassengerPhoneEmailJourney TypeOriginDestinationSeat ClassCoachSeat
-
-
- - - - -