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/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/main.ts b/apps/edr-freight-api/src/main.ts index 0fa1056dd..5b027448c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -33,6 +33,13 @@ async function bootstrap() { "delegator-position-id", "current-project-id", "current-position-id", + // x-prefixed variants sent by the user-management / record-management + // frontend modules (same values, different naming convention) + "x-organization-unit-id", + "x-delegator-id", + "x-delegator-position-id", + "x-current-project-id", + "x-current-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev 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/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts new file mode 100644 index 000000000..d8119930f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drop the `active_profile_type` "active mode" column. A booking/contract now + * resolves its company_profile from the trade direction at creation time (with + * a forwarder passing an explicit companyProfileId), so no per-user active mode + * is stored. `onboarding_step` / `onboarding_completed` are unaffected. + */ +export class DropActiveProfileTypeFromExternalProfiles2450000000000 + implements MigrationInterface +{ + name = 'DropActiveProfileTypeFromExternalProfiles2450000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + // Rebuild the mode the same way the original column was backfilled: + // importer first, then exporter, then whichever profile the company has. + await queryRunner.query(` + UPDATE freight.external_profiles ep + SET active_profile_type = cp.type + FROM ( + SELECT DISTINCT ON (company_id) company_id, type + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE ep.company_id = cp.company_id + AND ep.active_profile_type IS NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts new file mode 100644 index 000000000..e6cfe70c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface { + name = "AddCacBankPaymentMethod2460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // The entity + frontend already list 'cac-bank' as a valid method, but the + // DB enum was never extended. Filtering payments by 'cac-bank' cast the + // literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301. + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + // To roll back, recreate the type without the added value and update the column. + } +} diff --git a/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts new file mode 100644 index 000000000..fadacda69 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the + * vehicle link is optional and only for acquisitions that ARE a fleet vehicle. + */ +export class AddAcquisitionItemName2470000000000 implements MigrationInterface { + name = 'AddAcquisitionItemName2470000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + ADD COLUMN IF NOT EXISTS item_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + DROP COLUMN IF EXISTS item_name + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index 91ddaf1a9..4eb6ecc31 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -12,6 +12,7 @@ import { RESET_LINK_TTL_MS, } from "./forgot-password.service"; import { maskOtpTarget } from "./mask-target.util"; +import { isDomesticPhone } from "../otp/otp.service"; /** The account a staff-triggered reset would land on. */ export interface CustomerResetTarget { @@ -19,6 +20,12 @@ export interface CustomerResetTarget { name: string; email: string | null; phone: string | null; + /** + * Whether the SMS gateway (domestic-only) can reach `phone`. `null` when + * there is no phone. The backoffice uses this to disable the SMS channel for + * foreign numbers instead of sending a link that will never arrive. + */ + phoneIsDomestic: boolean | null; } export interface SentResetLink { @@ -58,6 +65,9 @@ export class CustomerResetService { name: `${profile.firstName} ${profile.lastName}`.trim(), email: user.email ?? null, phone: user.phoneNumber ?? null, + phoneIsDomestic: user.phoneNumber + ? isDomesticPhone(user.phoneNumber) + : null, }; } @@ -80,6 +90,17 @@ export class CustomerResetService { const target = this.forgotPasswordService.targetFor(user, channel); if (!target) return null; + // A foreign number is unreachable by the domestic-only SMS gateway — treat + // it like a missing phone rather than reporting "link sent" for a message + // that will never arrive. The backoffice disables the channel up front via + // `phoneIsDomestic`; this guards direct API calls. + if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { + this.logger.warn( + `Staff reset via SMS refused for user ${userId} — non-domestic phone`, + ); + return null; + } + // Mint first, send second: a failed send leaves an unused ticket that simply // expires, whereas sending a link before the ticket exists would hand the // customer a URL that is dead on arrival. 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-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 a42d23859..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, warnings: baseWarnings } = - 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; @@ -248,7 +252,7 @@ export class BookingPricingService { appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, warnings: [...ruleResult.warnings, ...baseWarnings], - hardBlocked: ruleResult.hardBlocked, + 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[]; warnings: string[] }> { + ): Promise<{ + lineItems: PriceLineItemDto[]; + usedRates: Rate[]; + warnings: string[]; + blocked: string[]; + }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -477,6 +486,7 @@ 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) { @@ -500,11 +510,14 @@ export class BookingPricingService { const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { // Never price this line off another container type's (or another - // route's) rate — an unpriced line with a warning is recoverable; a - // silently mischarged one is not. - warnings.push( + // 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 line was not priced.', + `the booking cannot be priced. Remove the ${label} line or ask EDR ` + + 'to configure its rate for this origin → destination.', ); continue; } @@ -587,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()], warnings }; + 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 1896034cc..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"]); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index bc8c80982..ac5b3c3cd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -480,6 +480,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( 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 d9dd53f94..e95cd65c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -635,12 +634,9 @@ export class BookingsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - // A customer can only book once their company has been approved. - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create bookings yet.", - ); - } + // A customer can only book once their company has been approved; the + // helper names the real status (suspended/blacklisted) when it isn't. + this.companiesService.assertCompanyActiveFor(company, 'bookings'); companyId = company.id; } @@ -746,21 +742,13 @@ export class BookingsService { ); companyProfileId = profile.id; } else if (companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } + // No explicit profile pin: resolve from the booking's trade direction + // (import→importer, export→exporter; otherwise the first profile). A + // forwarder booking sends dto.companyProfileId and takes the branch above. companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, tradeDirection, - fallbackType, ); // A customer booking under their own account may only do so once the @@ -1068,9 +1056,6 @@ export class BookingsService { await this.companiesService.resolveCompanyProfileIdForBooking( existing.companyId, tradeDirection, - existing.companyProfileId - ? undefined - : (existing.companyProfile?.type as ProfileType | undefined), ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); @@ -1228,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); @@ -1239,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)); @@ -1392,15 +1376,6 @@ export class BookingsService { } } - /** - * Resolve the active company_profile id a customer's bookings should be - * scoped to (importer/exporter mode). Null when not onboarded — callers fall - * back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - return this.companiesService.resolveActiveCompanyProfileId(userId); - } - /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong @@ -1583,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/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index eb4699008..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -576,4 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 6111bd32a..43d70ce19 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; -import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -353,21 +352,6 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } - @Patch("active-mode") - @ApiOperation({ - summary: "Switch the current user's active operational mode (importer/exporter)", - }) - async setActiveMode( - @CurrentUser() user: CurrentIamUser, - @Body() dto: SetActiveModeDto, - ): Promise { - const { profile, company } = await this.companiesService.setActiveMode( - user.id, - dto.type, - ); - return new CompanyInfoResponseDto(profile, company); - } - @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 3ac12b11a..db8db0d2e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository { AND ccr.deleted_at IS NULL )`; + /** + * The `sortBy = 'review'` queue ordering: whatever marketing must act on + * floats to the top. Tier 0 — submitted applications awaiting first approval + * (drafts excluded: nothing to review yet). Tier 1 — approved customers with + * a pending change request. Tier 2 — everyone else, drafts included. + */ + private static readonly REVIEW_TIER_SQL = `(CASE + WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0 + WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1 + ELSE 2 + END)`; + constructor( @InjectRepository(Company) repo: Repository, @@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository { status, onboardingCompleted, hasPendingChangeRequest, - sortBy = 'name', - sortOrder = 'ASC', + sortBy = 'review', + sortOrder = 'DESC', } = query; const qb = this.repository @@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository { } // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. + if (sortBy === 'review') { + // Queue ordering: actionable tiers first, newest first within each. The + // tier is selected under an alias because skip/take pagination with + // joins re-derives the ORDER BY in a subquery — a raw expression there + // breaks, a selected alias survives. + qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier') + .orderBy('review_tier', 'ASC') + .addOrderBy('company.createdAt', 'DESC'); + } else { + qb.orderBy(`company.${sortBy}`, sortOrder); + } const [items, total] = await qb - .orderBy(`company.${sortBy}`, sortOrder) // Names are not unique and createdAt can tie on bulk imports; the id // tiebreaker keeps paging stable instead of dropping/repeating rows. .addOrderBy('company.id', 'ASC') diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index df0e14998..3867bef3a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -201,18 +201,6 @@ export class CompaniesService { attributes: dto.attributes ?? null, }); - // Default active mode from the chosen role(s): importer wins when both are - // picked, otherwise the first allowed type chosen. - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - const chosenTypes = (dto.companyProfiles ?? []) - .map((p) => p.type) - .filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; - const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, @@ -220,7 +208,6 @@ export class CompaniesService { lastName: identity.lastName, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, - activeProfileType, onboardingStep: "company", }); @@ -293,11 +280,6 @@ export class CompaniesService { const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; const company = await this.companiesRepo.create({ name: identity.firstName @@ -316,7 +298,6 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, isPrimaryContact: true, - activeProfileType, onboardingStep: "company", onboardingCompleted: false, }); @@ -1090,6 +1071,23 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // Suspension and reactivation must carry a staff explanation — the customer + // sees it, so "why" can never be left blank. Reactivation is the + // active-write that leaves Suspended; a first approval stays note-free. + const reactivating = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if ( + (status === ProfileStatus.Suspended || reactivating) && + !note?.trim() + ) { + throw new BadRequestException( + status === ProfileStatus.Suspended + ? "A message explaining the suspension is required — the customer will see it." + : "A message explaining the reactivation is required — the customer will see it.", + ); + } + // A self-registered company is only reviewable once its owner submits the // onboarding wizard (markOnboardingComplete) — until then its profiles are // half-filled drafts and approving one would mint a reference against an @@ -1176,9 +1174,13 @@ export class CompaniesService { ); } - // Track the review outcome. Rejection keeps the note so the customer knows - // why; approval clears it. Any decision stamps the reviewer + time. - if (status === ProfileStatus.Rejected) { + // Track the review outcome. Rejection and suspension keep the note so the + // customer knows why; approval/reactivation clears it. Any decision stamps + // the reviewer + time. + if ( + status === ProfileStatus.Rejected || + status === ProfileStatus.Suspended + ) { patch.reviewNote = note ?? null; } else if (status === ProfileStatus.Active) { patch.reviewNote = null; @@ -1192,23 +1194,50 @@ export class CompaniesService { if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); - // Approving any profile promotes a pending company to active, so the - // customer can start working as soon as their first profile is cleared. - if (status === ProfileStatus.Active) { + // Every reviewed transition that changes what the customer can do is told + // to them, carrying the staff message so they know why. Approval has no + // message (the note is cleared); the others require one. + const change = + status === ProfileStatus.Suspended + ? "suspended" + : status === ProfileStatus.Rejected + ? "rejected" + : status === ProfileStatus.Active + ? existing.status === ProfileStatus.Suspended + ? "reactivated" + : "approved" + : null; + if (change) { const company = await this.companiesRepo.findById(updated.companyId); - if (company && company.status === CompanyStatus.Pending) { - await this.companiesRepo.update(updated.companyId, { - status: CompanyStatus.Active, - }); + if (company) { + this.companyNotifier.profileStatusChanged( + company, + updated.type, + change, + note ?? "", + ); + // The first approved role promotes a pending company to active — a + // bigger event (the account itself goes live), so tell them that too. + if ( + status === ProfileStatus.Active && + company.status === CompanyStatus.Pending + ) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + this.companyNotifier.companyApproved(company); + } } } return updated; } /** - * Customer reapplies for a rejected operational role (after fixing whatever the - * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and - * clear the rejection note so it re-enters the approval queue. + * Customer reapplies for a rejected or suspended operational role (after + * fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it + * back to Pending and clear the review note so it re-enters the approval + * queue. Suspension is a staff lockout, so resubmitting is an appeal — the + * backoffice still has to approve before the role goes live again. */ async reapplyCompanyProfile( userId: string, @@ -1223,9 +1252,12 @@ export class CompaniesService { if (!target || target.companyId !== companyId) { throw new NotFoundException(`Company profile ${profileId} not found`); } - if (target.status !== ProfileStatus.Rejected) { + if ( + target.status !== ProfileStatus.Rejected && + target.status !== ProfileStatus.Suspended + ) { throw new BadRequestException( - "Only a rejected role can be resubmitted for approval", + "Only a rejected or suspended role can be resubmitted for approval", ); } @@ -1349,10 +1381,9 @@ export class CompaniesService { /** * Create a single operational profile for the current user's company. The new - * role starts Pending, so it deliberately does NOT become the active mode: - * switching onto an unapproved profile would strip the user of `canBook` and - * block them from creating contracts under the role they already had approved. - * Callers switch explicitly via {@link setActiveMode} once the role is Active. + * role starts Pending and carries no reference until a backoffice reviewer + * approves it; a booking/contract resolves its profile from the trade + * direction at creation time, so no "active mode" is stored. */ async createCompanyProfileForUser( userId: string, @@ -1387,40 +1418,6 @@ export class CompaniesService { return created; } - /** - * Switch the user's active operational mode. The target profile must already - * exist — clients create it first via createCompanyProfileForUser. - */ - async setActiveMode( - userId: string, - type: ProfileType, - ): Promise<{ profile: ExternalProfile; company: Company }> { - const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) - throw new NotFoundException(`Profile for user ${userId} not found`); - - const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - if (!allowedTypes.includes(type)) { - throw new BadRequestException( - `Profile type "${type}" is not allowed for company type "${company.type}"`, - ); - } - - const existing = await this.companyProfilesRepo.findByType(companyId, type); - if (!existing) { - throw new ConflictException( - `No ${type} profile exists yet — create it before switching`, - ); - } - - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - - return this.getCompanyInfoByUserId(userId); - } - async setOnboardingStep(userId: string, step: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -1611,21 +1608,68 @@ export class CompaniesService { } /** - * Block a customer from booking under a profile that isn't approved yet. - * Called from the booking-create path for self-service bookings; staff- and - * government-initiated bookings bypass this. No-op when the profile can't be - * found (defensive — resolution is best-effort upstream). + * Block a self-service action when the company account isn't active, naming + * the actual status — a suspended customer told "awaiting approval" has no + * idea what happened or who to call. + */ + assertCompanyActiveFor(company: Company, action: string): void { + if (company.status === CompanyStatus.Active) return; + switch (company.status) { + case CompanyStatus.Suspended: + throw new ForbiddenException( + `Your company account is suspended — you can't create ${action} right now. ` + + `Please contact EDR support for details.`, + ); + case CompanyStatus.Blacklisted: + throw new ForbiddenException( + `Your company account is blacklisted — you can't create ${action}. ` + + `Please contact EDR support.`, + ); + default: + throw new ForbiddenException( + `Your company is awaiting approval — you can't create ${action} yet.`, + ); + } + } + + /** + * Block a customer from booking under a profile that isn't approved yet — or + * that a reviewer has since suspended. Called from the booking/contract + * create path for self-service actions; staff- and government-initiated ones + * bypass this. No-op when the profile can't be found (defensive — resolution + * is best-effort upstream). The message names the profile's real status: + * suspension in particular is per-role, so the customer must learn which + * operation is blocked (their other roles still work). */ async assertCompanyProfileApprovedForBooking( companyProfileId: string, ): Promise { const profile = await this.companyProfilesRepo.findById(companyProfileId); if (!profile) return; - if (profile.status !== ProfileStatus.Active) { - const role = profile.type.replace(/_/g, " "); - throw new ForbiddenException( - `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, - ); + if (profile.status === ProfileStatus.Active) return; + + const role = profile.type.replace(/_/g, " "); + switch (profile.status) { + case ProfileStatus.Suspended: + throw new ForbiddenException( + `Your ${role} role is suspended${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Your other roles are unaffected. Please contact EDR support to resolve this.`, + ); + case ProfileStatus.Blacklisted: + throw new ForbiddenException( + `Your ${role} role is blacklisted. Please contact EDR support.`, + ); + case ProfileStatus.Rejected: + throw new ForbiddenException( + `Your ${role} role was rejected${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Amend and resubmit it from your settings page.`, + ); + default: + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`, + ); } } @@ -2244,15 +2288,14 @@ export class CompaniesService { /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → - * exporter profile; for DOMESTIC or a forwarder/single-profile company (or - * when the natural profile doesn't exist) it falls back to the user's active - * profile, then the company's first profile. Returns null when the company - * has no profiles at all. + * exporter profile; for DOMESTIC (or when the natural profile doesn't exist, + * e.g. a freight forwarder) it falls back to the company's first profile. + * Callers that need a specific role (a forwarder) pass an explicit + * companyProfileId instead. Returns null when the company has no profiles. */ async resolveCompanyProfileIdForBooking( companyId: string, tradeDirection: string, - fallbackType?: ProfileType | null, ): Promise { const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); if (profiles.length === 0) return null; @@ -2264,30 +2307,12 @@ export class CompaniesService { ? ProfileType.exporter : null; - const byType = (type?: ProfileType | null) => - type ? profiles.find((p) => p.type === type) : undefined; - - const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; + const match = + (naturalType && profiles.find((p) => p.type === naturalType)) ?? + profiles[0]; return match?.id ?? null; } - /** - * Resolve the company_profile a customer's data should be scoped to, from - * their persisted active mode. Returns null when nothing can be resolved - * (not onboarded yet) so callers can fall back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - try { - const { profile, company } = await this.getCompanyInfoByUserId(userId); - const type = profile.activeProfileType; - if (!type) return null; - const match = company.companyProfiles?.find((p) => p.type === type); - return match?.id ?? null; - } catch { - return null; - } - } - async fetchETradeData(tin: string) { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index f71a67976..66f88e9f8 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -59,23 +59,106 @@ export class CompanyNotifierService { } } + /** SMS + email + in-app account-status item to the company contact. */ + private notifyAccount( + company: Company, + title: string, + body: string, + link = "/settings", + ): void { + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link, + data: { companyId: company.id, status: company.status }, + priority: NotificationPriority.HIGH, + }); + } + /** - * Tell the customer their account was suspended or blacklisted. Called only on - * a real transition into one of those statuses; other status writes are silent. + * Tell the customer their account changed status. Fires on the transitions + * that change what they can do: suspended/blacklisted (locked out) and + * reactivated (back to Active from a lockout). Silent otherwise. */ statusChanged(company: Company, previous: CompanyStatus): void { const status = company.status; if (status === previous) return; + + if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) { + this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`); + this.notifyAccount( + company, + "Account reactivated", + "Your company account has been reactivated. " + + "You can submit new contracts and bookings again.", + ); + return; + } + if (!PUNITIVE_STATUSES.includes(status)) return; const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; - const title = `Account ${label}`; - const body = - `Your company account has been ${label}. ` + - `You will not be able to submit new contracts or bookings. ` + - `Please contact EDR support for assistance.`; - this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + this.notifyAccount( + company, + `Account ${label}`, + `Your company account has been ${label}. ` + + `You will not be able to submit new contracts or bookings. ` + + `Please contact EDR support for assistance.`, + ); + } + + /** + * Tell the customer their company account was approved and is now live — the + * first operational role clearing review promotes a pending company to Active. + */ + companyApproved(company: Company): void { + this.logger.log(`ACCOUNT_APPROVED — ${company.id}`); + this.notifyAccount( + company, + "Account approved", + "Your company account has been approved and is now active. " + + "You can start submitting bookings and contracts.", + "/dashboard", + ); + } + + /** + * Tell the customer one of their operational roles changed review status — + * approved, rejected, suspended, or reactivated — quoting the staff message + * when one was given (rejection/suspension/reactivation require one; approval + * carries none). + */ + profileStatusChanged( + company: Company, + profileType: string, + change: "approved" | "rejected" | "suspended" | "reactivated", + staffMessage: string, + ): void { + const title = `${profileType} role ${change}`; + const consequence: Record = { + approved: "You can now operate under this role.", + rejected: + "You will not be able to operate under this role. Amend the required " + + "documents and resubmit it for approval from your settings page.", + suspended: + "You will not be able to operate under this role until it is " + + "reactivated; your other roles are unaffected.", + reactivated: "You can operate under this role again.", + }; + const message = staffMessage.trim(); + const body = + `Your company's ${profileType} role has been ${change}. ` + + `${consequence[change]}` + + (message ? ` Message from EDR staff: ${message}` : ""); + + this.logger.log( + `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, + ); void this.notifyContact(company, `${title}. ${body}`); void this.inbox.notify({ recipients: { companyId: company.id }, @@ -84,7 +167,7 @@ export class CompanyNotifierService { title, body, link: "/settings", - data: { companyId: company.id, status }, + data: { companyId: company.id, profileType, change, staffMessage: message }, priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 9a4fb330a..2634e0943 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -24,7 +24,7 @@ export class CompanyInfoResponseDto { company: Company, changeRequest?: CompanyChangeRequest | null, ) { - this.profile = new ResponseExternalProfileDto(profile, company); + this.profile = new ResponseExternalProfileDto(profile); this.company = new ResponseCompanyDto(company); const open = diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 8d4910ded..ffb600e36 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -60,15 +60,19 @@ export class ListCompaniesQueryDto { hasPendingChangeRequest?: boolean; @ApiPropertyOptional({ - enum: ["name", "createdAt", "updatedAt"], - default: "name", - description: "Column to order by. Defaults to name for backwards compatibility.", + enum: ["review", "name", "createdAt", "updatedAt"], + default: "review", + description: + "Column to order by. The default `review` is a review-queue ordering: " + + "companies awaiting first approval, then those with a pending change " + + "request, then everyone else — newest first within each group. The " + + "other values are plain column sorts.", }) @IsOptional() - @IsIn(["name", "createdAt", "updatedAt"]) - sortBy?: "name" | "createdAt" | "updatedAt"; + @IsIn(["review", "name", "createdAt", "updatedAt"]) + sortBy?: "review" | "name" | "createdAt" | "updatedAt"; - @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @IsIn(["ASC", "DESC"]) diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 256641074..916bb940d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,8 +1,6 @@ -import { Company } from '../entities/company.entity'; import { ExternalProfile, } from '../entities/external-profile.entity'; -import { ProfileType } from '../entities/company-profile.entity'; export class ResponseExternalProfileDto { id: string; @@ -13,20 +11,12 @@ export class ResponseExternalProfileDto { nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; - /** The active operational mode (importer/exporter/forwarder). */ - activeProfileType?: ProfileType | null; - /** - * The id of the company_profile matching activeProfileType, resolved - * server-side so the client never re-derives it. Null until a company - * (with profiles) is loaded and a matching profile exists. - */ - activeCompanyProfileId?: string | null; onboardingStep?: string | null; onboardingCompleted: boolean; createdAt: Date; updatedAt: Date; - constructor(profile: ExternalProfile, company?: Company) { + constructor(profile: ExternalProfile) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -35,13 +25,8 @@ export class ResponseExternalProfileDto { this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; - this.activeProfileType = profile.activeProfileType ?? null; this.onboardingStep = profile.onboardingStep ?? null; this.onboardingCompleted = profile.onboardingCompleted ?? false; - this.activeCompanyProfileId = - company?.companyProfiles?.find( - (p) => p.type === profile.activeProfileType, - )?.id ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts deleted file mode 100644 index ac8f57a93..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { ProfileType } from '../entities/company-profile.entity'; - -export class SetActiveModeDto { - @IsEnum(ProfileType) - type!: ProfileType; -} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 93e499b5e..84f644091 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,7 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; -import { ProfileType } from './company-profile.entity'; @Entity({ schema: 'freight', name: 'external_profiles' }) @Index(['userId']) @@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) isPrimaryContact!: boolean; - /** - * The operational profile the user is currently "in" (importer vs exporter, - * or the single forwarder profile). Drives header switching and scopes the - * customer's bookings / dashboard to that company_profile. Nullable for - * users who haven't picked a role yet. - */ - @Column({ - name: 'active_profile_type', - type: 'varchar', - length: 32, - nullable: true, - enum: ProfileType, - }) - activeProfileType?: ProfileType | null; - /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ @Column({ name: 'onboarding_step', 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 df930d805..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 @@ -320,6 +320,12 @@ export class ContractBookingService { 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. @@ -744,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, { @@ -1785,6 +1796,10 @@ export class ContractBookingService { // 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, @@ -1892,7 +1907,10 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors: [...scopeErrors, ...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/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 6ca4473cd..266a00044 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -196,7 +196,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // Staff see every contract; customers are force-scoped to their own company. - if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { return this.contractsService.findAll(filter); } const userId = user?.id; @@ -273,7 +276,8 @@ export class ContractsController { if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && - !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) + !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } @@ -475,7 +479,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { view, html, signatures } = @@ -510,7 +517,10 @@ export class ContractsController { @Res() res: Response, ): Promise { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { stream, record } = await this.transitionService.streamContractPdf(id); @@ -566,9 +576,12 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // H12(c): a customer may only renew a contract their company owns. Staff - // with bookings.view bypass, mirroring getContractView/downloadContractDocument. + // with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.transitionService.renew(id, resolveAuthUserId(user)); @@ -592,9 +605,12 @@ export class ContractsController { @UploadedFiles() files: Express.Multer.File[], ) { // H12(c): only the owning company's customer may upload clearance docs. - // Staff with bookings.view bypass, mirroring the other contract handlers. + // Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.clearanceService.uploadDocuments(id, files ?? []); 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 a675adf39..65f0637f0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; -import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; @@ -181,11 +179,7 @@ export class ContractsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create contracts yet.", - ); - } + this.companiesService.assertCompanyActiveFor(company, 'contracts'); companyId = company.id; } @@ -193,31 +187,31 @@ export class ContractsService { this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); - // Stamp the operational profile (importer/exporter) for portal scoping. + // Stamp the operational profile for portal scoping. A forwarder contract + // pins its profile explicitly (trade direction can't tell it apart from a + // direct import/export); everything else resolves from the trade direction. let companyProfileId: string | null = null; if (!isGovernment && companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } - companyProfileId = - await this.companiesService.resolveCompanyProfileIdForBooking( - companyId, - dto.tradeDirection, - fallbackType, - ); + if (dto.companyProfileId) { + const profile = + await this.companiesService.getActiveCompanyProfileForBooking( + companyId, + dto.companyProfileId, + ); + companyProfileId = profile.id; + } else { + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + dto.tradeDirection, + ); - const customerSelfBooking = !dto.companyId && !!userId; - if (customerSelfBooking && companyProfileId) { - await this.companiesService.assertCompanyProfileApprovedForBooking( - companyProfileId, - ); + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 688e0b4c7..fb4f40654 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -124,6 +124,16 @@ export class CreateContractDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile to stamp the contract to (a forwarder contract); ' + + 'commercial contracts otherwise auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) @IsIn([...CONTRACT_KINDS]) contractKind!: string; 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/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/otp/otp.controller.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts index cac5fdba0..3aefe7cdb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { OtpController } from './otp.controller'; +import { OtpService } from './otp.service'; describe('OtpController', () => { let controller: OtpController; @@ -7,6 +8,9 @@ describe('OtpController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [OtpController], + providers: [ + { provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } }, + ], }).compile(); controller = module.get(OtpController); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 0494b48b6..00f8d107a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -1,33 +1,46 @@ -import { OtpService, normalizeOtpTarget } from './otp.service'; +import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service"; -describe('normalizeOtpTarget', () => { - it('canonicalises Ethiopian forms to one E.164 key', () => { - const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099']; +describe("normalizeOtpTarget", () => { + it("canonicalises Ethiopian forms to one E.164 key", () => { + const forms = [ + "+251986680099", + "251986680099", + "0986680099", + "+251 98 668 0099", + ]; const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone); - expect(new Set(keys)).toEqual(new Set(['+251986680099'])); + expect(new Set(keys)).toEqual(new Set(["+251986680099"])); }); - it('maps local 07… mobile to +2517…', () => { - expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678'); - }); - - it('canonicalises email case and surrounding whitespace to one key', () => { - const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM']; + it("canonicalises email case and surrounding whitespace to one key", () => { + const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"]; const keys = forms.map((email) => normalizeOtpTarget({ email }).email); - expect(new Set(keys)).toEqual(new Set(['a@b.com'])); + expect(new Set(keys)).toEqual(new Set(["a@b.com"])); }); - it('keeps an already-normalised email stable (idempotent)', () => { - const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!; + it("keeps an already-normalised email stable (idempotent)", () => { + const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!; expect(normalizeOtpTarget({ email: once }).email).toBe(once); }); - it('keeps an already-normalised number stable (idempotent)', () => { - const once = normalizeOtpTarget({ phone: '0986680099' }).phone!; + it("keeps an already-normalised number stable (idempotent)", () => { + const once = normalizeOtpTarget({ phone: "0986680099" }).phone!; expect(normalizeOtpTarget({ phone: once }).phone).toBe(once); }); }); +describe("isDomesticPhone", () => { + it.each(["+251986680099", "0986680099", "251986680099"])( + "accepts Ethiopian mobile form %s", + (phone) => expect(isDomesticPhone(phone)).toBe(true), + ); + + it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])( + "rejects non-domestic or malformed %s", + (phone) => expect(isDomesticPhone(phone)).toBe(false), + ); +}); + interface FakeRow { id: string; phone?: string; @@ -51,7 +64,8 @@ function makeService( let nextId = 1; const matches = (row: FakeRow, t: { phone?: string; email?: string }) => - (!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone); + (!!t.email && row.email === t.email) || + (!!t.phone && row.phone === t.phone); const repo = { findByTarget: jest.fn( @@ -89,30 +103,30 @@ function makeService( return { service, sms, email, rows: () => rows }; } -describe('OtpService — send/verify agree across phone formats', () => { - it('verifies a code sent to +251… when verify is called with 09…', async () => { +describe("OtpService — send/verify agree across phone formats", () => { + it("verifies a code sent to +251… when verify is called with 09…", async () => { const { service, rows } = makeService(); - await service.sendOtp({ phone: '+251986680099' }); + await service.sendOtp({ phone: "+251986680099" }); await expect( - service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp), + service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); - it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => { + it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => { const { service, rows } = makeService(); - await service.sendOtp({ email: ' User@Example.COM ' }); + await service.sendOtp({ email: " User@Example.COM " }); await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), + service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); }); -describe('OtpService — dual-channel send', () => { - const both = { phone: '0986680099', email: 'User@Example.COM' }; +describe("OtpService — dual-channel send", () => { + const both = { phone: "0986680099", email: "User@Example.COM" }; - it('sends ONE code to both transports', async () => { + it("sends ONE code to both transports", async () => { const { service, sms, email, rows } = makeService(); await service.sendOtp(both); @@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => { // Same secret on both messages — the user types whichever arrives first. expect(sms.sendSms).toHaveBeenCalledWith( expect.objectContaining({ - to: '+251986680099', + to: "+251986680099", message: expect.stringContaining(otp), }), ); expect(email.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ - to: 'user@example.com', + to: "user@example.com", text: expect.stringContaining(otp), }), ); // One row, both channels canonicalised. expect(rows()).toHaveLength(1); expect(rows()[0]).toMatchObject({ - phone: '+251986680099', - email: 'user@example.com', + phone: "+251986680099", + email: "user@example.com", }); }); it.each([ - ['phone alone', { phone: '0986680099' }], - ['email alone', { email: 'user@example.com' }], - ['both', both], - ])('verifies a dual-channel code when quoted back by %s', async (_label, target) => { - const { service, rows } = makeService(); - await service.sendOtp(both); + ["phone alone", { phone: "0986680099" }], + ["email alone", { email: "user@example.com" }], + ["both", both], + ])( + "verifies a dual-channel code when quoted back by %s", + async (_label, target) => { + const { service, rows } = makeService(); + await service.sendOtp(both); - await expect( - service.verifyOtpForAction(target, rows()[0]!.otp), - ).resolves.toEqual({ success: true }); - }); + await expect( + service.verifyOtpForAction(target, rows()[0]!.otp), + ).resolves.toEqual({ success: true }); + }, + ); - it('consuming the code via one channel kills the other', async () => { + it("consuming the code via one channel kills the other", async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; - await service.verifyOtpForAction({ email: 'user@example.com' }, otp); + await service.verifyOtpForAction({ email: "user@example.com" }, otp); // Single-use is per-code, not per-channel: the phone half must be dead too. await expect( - service.verifyOtpForAction({ phone: '0986680099' }, otp), + service.verifyOtpForAction({ phone: "0986680099" }, otp), ).rejects.toThrow(/No verification code was requested/); }); - it('replaces an overlapping single-channel row instead of colliding with it', async () => { + it("replaces an overlapping single-channel row instead of colliding with it", async () => { const { service, rows } = makeService(); // A pending signup code on the phone only, then a dual-channel send. - await service.sendOtp({ phone: '0986680099' }); + await service.sendOtp({ phone: "0986680099" }); await service.sendOtp(both); expect(rows()).toHaveLength(1); - expect(rows()[0]).toMatchObject({ email: 'user@example.com' }); + expect(rows()[0]).toMatchObject({ email: "user@example.com" }); }); - it('degrades to one channel when the account has only one contact', async () => { + it("degrades to one channel when the account has only one contact", async () => { const { service, sms, email } = makeService(); - await service.sendOtp({ phone: '0986680099' }); + await service.sendOtp({ phone: "0986680099" }); expect(sms.sendSms).toHaveBeenCalledTimes(1); expect(email.sendEmail).not.toHaveBeenCalled(); }); - it('still succeeds when one transport throws', async () => { + it("skips SMS for a foreign number when email is available", async () => { + const { service, sms, email, rows } = makeService(); + await service.sendOtp({ phone: "+14155550123", email: "user@example.com" }); + + // The gateway is domestic-only — email is the delivery route, but the + // foreign phone stays on the row so verify still matches either channel. + expect(sms.sendSms).not.toHaveBeenCalled(); + expect(email.sendEmail).toHaveBeenCalledTimes(1); + await expect( + service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp), + ).resolves.toEqual({ success: true }); + }); + + it("still attempts SMS for a foreign number when it is the only channel", async () => { + const { service, sms } = makeService(); + await service.sendOtp({ phone: "+14155550123" }); + + expect(sms.sendSms).toHaveBeenCalledTimes(1); + }); + + it("still succeeds when one transport throws", async () => { const { service, rows } = makeService({ sms: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, }); @@ -197,24 +234,24 @@ describe('OtpService — dual-channel send', () => { }); // The code is live and verifiable on the channel that worked. await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), + service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); - it('fails the request when every transport throws', async () => { + it("fails the request when every transport throws", async () => { const { service } = makeService({ sms: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, email: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, }); - await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP'); + await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP"); }); - it('shares one brute-force budget across both channels', async () => { + it("shares one brute-force budget across both channels", async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; @@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => { // Alternating channels must not hand the attacker two independent budgets: // 5 wrong guesses in total burn the code regardless of how they are split. for (const target of [ - { phone: '0986680099' }, - { email: 'user@example.com' }, - { phone: '0986680099' }, - { email: 'user@example.com' }, + { phone: "0986680099" }, + { email: "user@example.com" }, + { phone: "0986680099" }, + { email: "user@example.com" }, ]) { - await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow( - 'Invalid verification code', - ); + await expect( + service.verifyOtpForAction(target, "000000"), + ).rejects.toThrow("Invalid verification code"); } await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, '000000'), + service.verifyOtpForAction({ email: "user@example.com" }, "000000"), ).rejects.toThrow(/Too many incorrect attempts/); // Burned: even the correct code no longer works. diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index e19323067..557548b00 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> { */ function normalizePhone(rawPhone: string): string { const raw = rawPhone.trim(); - const digits = raw.replace(/[^\d+]/g, ''); - if (digits.startsWith('+')) return digits; - const bare = digits.replace(/^0+/, ''); + const digits = raw.replace(/[^\d+]/g, ""); + if (digits.startsWith("+")) return digits; + const bare = digits.replace(/^0+/, ""); if (/^251\d{9}$/.test(digits)) return `+${digits}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; // Unknown shape (foreign number, already-clean intl without +) — prefix + if @@ -46,6 +46,16 @@ function normalizePhone(rawPhone: string): string { return digits.length >= 11 ? `+${digits}` : raw; } +/** + * Whether a phone is an Ethiopian mobile the SMS gateway can actually reach — + * the carrier integration is domestic-only, so a send to anything else is + * queued and silently lost. Callers use this to fall back to email instead of + * pretending an SMS is on its way. + */ +export function isDomesticPhone(rawPhone: string): boolean { + return /^\+2519\d{8}$/.test(normalizePhone(rawPhone)); +} + /** * Canonicalise every channel present on the target. Each field is normalised * independently — a dual-channel target must end up with both halves in their @@ -143,6 +153,20 @@ export class OtpService { // /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists // in the codebase yet. + // A foreign number is unreachable by the domestic-only SMS gateway; when + // email is also on the target, go email-only rather than queueing an SMS + // that will never arrive. With no email the SMS attempt stays — it is the + // only route there is. + const smsPhone = + target.phone && (!target.email || isDomesticPhone(target.phone)) + ? target.phone + : null; + if (target.phone && !smsPhone) { + this.logger.warn( + `otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`, + ); + } + // Fan out to every channel the target has, independently: one transport // being down must not suppress the other, which is the whole point of // sending to both. Each helper swallows its own failure so a rejected @@ -150,16 +174,14 @@ export class OtpService { const outcomes = ( await Promise.all([ target.email ? this.dispatchEmail(target.email, otp) : null, - target.phone ? this.dispatchSms(target.phone, otp) : null, + smsPhone ? this.dispatchSms(smsPhone, otp) : null, ]) ).filter((outcome): outcome is DispatchOutcome => outcome !== null); for (const outcome of outcomes) { this.logger.log( - `otp.dispatch channel=${outcome.channel} target=${label} queued=${ - outcome.queued - } latencyMs=${Date.now() - startedAt}${ - outcome.error ? ` error=${outcome.error}` : "" + `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued + } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" }`, ); } @@ -183,8 +205,7 @@ export class OtpService { // user who never receives a code — indistinguishable from carrier loss, // and the misleading success response makes it look like our side worked. this.logger.error( - `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${ - process.env.RABBITMQ_ENABLED ?? "unset" + `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset" } — no transport reported hand-off; no code will arrive for this send`, ); } @@ -209,8 +230,7 @@ export class OtpService { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( - `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${ - Date.now() - startedAt + `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt }: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); @@ -270,9 +290,7 @@ export class OtpService { * address while printing the credential next to it would buy nothing. */ private targetLabel(target: OtpTarget): string { - return ( - [target.email, target.phone].filter(Boolean).join("+") || "unknown" - ); + return [target.email, target.phone].filter(Boolean).join("+") || "unknown"; } /** @@ -288,9 +306,8 @@ export class OtpService { ) { const line = `otp.verify channels=${channelsOf(target).join( "+", - )} target=${this.targetLabel(target)} mode=${mode} result=${result}${ - detail ? ` ${detail}` : "" - }`; + )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : "" + }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); } @@ -439,7 +456,12 @@ export class OtpService { await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); - this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`); + this.logVerify( + target, + "action", + "expired", + `ageMs=${ageMs} ttlMs=${ttlMs}`, + ); throw new BadRequestException( "Verification code has expired. Request a new one.", ); diff --git a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts index 943d79296..cfab53a5d 100644 --- a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, IsEnum, IsBoolean, + MinLength, } from 'class-validator'; import { VendorType } from '../entities/vendor.entity'; import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; @@ -72,6 +73,11 @@ export class UpdateVendorDto { } export class CreateAcquisitionDto { + /** WHAT was acquired — required so an acquisition can't be saved empty. */ + @IsString() + @MinLength(2) + itemName!: string; + @IsOptional() @IsUUID() vehicleId?: string; @@ -120,6 +126,11 @@ export class CreateAcquisitionDto { } export class UpdateAcquisitionDto { + @IsOptional() + @IsString() + @MinLength(2) + itemName?: string; + @IsOptional() @IsUUID() vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts index d4f781c15..e1a9f4ff5 100644 --- a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -18,6 +18,12 @@ export enum AcquisitionStatus { @Entity({ name: 'asset_acquisitions', schema: 'freight' }) @Index(['vehicleId', 'acquisitionDate']) export class AssetAcquisition extends BaseEntity { + /** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */ + @Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true }) + itemName?: string; + + /** Optional link — only when the acquisition IS a fleet vehicle. Parts and + * general procurement stay unlinked so reports don't misattribute them. */ @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts new file mode 100644 index 000000000..8004d9d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ProcurementService } from './procurement.service'; +import { AcquisitionType } from './entities/asset-acquisition.entity'; + +// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may. +describe('ProcurementService acquisition lease-field guard', () => { + const repo = { + createAcquisition: jest.fn(async (dto) => dto), + findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })), + updateAcquisition: jest.fn(async (_id, dto) => dto), + }; + const svc = new ProcurementService(repo as never); + + it('rejects a PURCHASE with lease dates', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + } as never), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a LEASE with lease dates and a plain PURCHASE', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Rented crane', + acquisitionType: AcquisitionType.LEASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + leaseEnd: '2027-07-01', + } as never), + ).resolves.toBeDefined(); + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + } as never), + ).resolves.toBeDefined(); + }); + + it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => { + await expect( + svc.updateAcquisition('a1', { monthlyPayment: 500 } as never), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts index e799d5ff9..7095a6a09 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { ProcurementRepository } from './procurement.repository'; import { Vendor } from './entities/vendor.entity'; -import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity'; import { AssetDisposal } from './entities/asset-disposal.entity'; import { CreateVendorDto, @@ -51,7 +51,23 @@ export class ProcurementService { } // ---- Acquisitions ---- + /** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */ + private assertLeaseFieldsValid(dto: { + acquisitionType?: string; + leaseStart?: string; + leaseEnd?: string; + monthlyPayment?: number; + }): void { + if (dto.acquisitionType !== AcquisitionType.PURCHASE) return; + if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) { + throw new BadRequestException( + 'Lease start/end and monthly payment are not valid for a PURCHASE acquisition', + ); + } + } + async createAcquisition(dto: CreateAcquisitionDto): Promise { + this.assertLeaseFieldsValid(dto); return this.procurementRepository.createAcquisition(dto); } @@ -64,6 +80,20 @@ export class ProcurementService { } async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + // Validate against the resulting record, not just the patch — switching an + // acquisition to PURCHASE must also shed any stored lease terms. + const existing = await this.procurementRepository.findAcquisitionById(id); + if (existing) { + const next = { ...existing, ...dto }; + if (next.acquisitionType === AcquisitionType.PURCHASE) { + this.assertLeaseFieldsValid({ + acquisitionType: next.acquisitionType, + leaseStart: dto.leaseStart, + leaseEnd: dto.leaseEnd, + monthlyPayment: dto.monthlyPayment, + }); + } + } return this.procurementRepository.updateAcquisition(id, dto); } 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 index c43e7e4e0..b0ba2c8d5 100644 --- 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 @@ -12,7 +12,8 @@ import { 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 { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; @@ -25,14 +26,14 @@ export class YardDistancesController { constructor(private readonly service: YardDistancesService) {} @Get() - @RuleEngineView('yard-distances') + @StaffReference() @ApiOperation({ summary: 'List yard distances' }) findAll(@Query() query: ListYardDistancesQueryDto) { return this.service.findAll(query); } @Get(':id') - @RuleEngineView('yard-distances') + @StaffReference() @ApiOperation({ summary: 'Get a yard distance 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/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/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/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-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-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 463ae7ea8..3e4fab0d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -253,12 +253,18 @@ export function minLocomotiveLimits( maxTrainLengthMeters: Math.min( ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), - // Weakest locomotive's tolerance governs the set, same as its caps. - overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))), - overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))), + // Weakest CONFIGURED tolerance governs the set — a locomotive with no + // tolerance set has no opinion, it does not zero out the others. + overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)), + overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)), }; } +function minConfigured(values: Array): number { + const configured = values.filter((v) => v != null).map((v) => num(v)); + return configured.length ? Math.min(...configured) : 0; +} + /** Per-booking train length from wagon count and freight-specific wagon type length. */ export function bookingTrainLengthMeters( freightType: string | null | undefined, 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..6757a6e21 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 @@ -80,7 +80,12 @@ const makeBooking = ( describe('TrainSchedulingService', () => { let service: TrainSchedulingService; - let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock }; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + query: jest.Mock; + manager: { getRepository: jest.Mock }; + }; let bookingsRepository: Record; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock }; @@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => { let wagonAllocationBulkLoadsRepository: Record; beforeEach(() => { + // findGroupSiblings runs a query builder off dataSource.manager; default it + // to "no sibling schedules" so isolated unit tests don't need to wire it. + const emptySiblingQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; dataSource = { getRepository: jest.fn(), transaction: jest.fn(), // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows". query: jest.fn().mockResolvedValue([]), + manager: { + getRepository: jest.fn(() => ({ + createQueryBuilder: jest.fn(() => emptySiblingQb), + })), + }, }; bookingsRepository = { findEligibleForScheduling: jest.fn(), @@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => { findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), updateStatus: jest.fn(), + maxReferenceSequence: jest.fn().mockResolvedValue(0), }; trainScheduleBookingsRepository = { findByBookingIds: jest.fn(), + findByScheduleId: jest.fn().mockResolvedValue([]), createMany: jest.fn(), deleteByScheduleAndBooking: jest.fn(), }; @@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => { }); it('allows preview when bookings are already on the target schedule', async () => { - const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; + // A booking already pinned to the target schedule is exempt from the + // corridor/day/status gates — mark it so on the entity, matching the link row. + const bookings = [ + { ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' }, + ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); @@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(true); }); - it('allows preview when selected bookings are on different schedule dates', async () => { + it('flags a booking scheduled for a different day than the train departure', async () => { + // The old cross-booking "must share the same schedule date" rule is gone; + // the live rule is that every booking must match the departure day. b2 + // departs a day later, so it's the one flagged. const bookings = [ makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'), @@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => { expect(result.violations).not.toContain( 'Selected bookings must share the same schedule date', ); - expect(result.valid).toBe(true); + expect(result.violations.some((v) => v.includes('different day'))).toBe(true); + expect(result.valid).toBe(false); }); it('rejects bookings that are not in schedulable status', async () => { @@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => { originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, + status: 'AVAILABLE', + direction: 'IMPORT', }; const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' }; @@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => { const trainScheduleRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), + // findGroupWindowAnchor looks for same-day sibling schedules; none here. + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), }; const trainSetRepo = { create: jest.fn().mockImplementation((value) => value), @@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => { callback(manager), ); + // Departure must clear the import lead window (≥ importWindowLeadDays ahead + // of now), so use a comfortably-future date rather than a hardcoded one. + const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); const result = await service.createContainerTrainSchedule({ routeId: 'route-1', - scheduleDate: '2026-06-20T08:00:00.000Z', + scheduleDate: futureDeparture, locomotiveIds: ['loc-1', 'loc-2'], }); expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(trainSetLocomotiveRepo.save).toHaveBeenCalled(); - expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith( - { id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) }, - { status: 'ASSIGNED' }, - ); + // Advance scheduling locks locomotives but does NOT flip them to ASSIGNED — + // one locomotive may sit on several future schedules. + expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled(); expect(result.id).toBe('schedule-1'); }); @@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => { destinationYardId: 'yard-destination', status: 'PAID', bookingContainers: [], - cargoType: { code: 'COFFEE' }, + cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] }, }; wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { @@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => { }); expect(result.valid).toBe(true); - expect(result.summary.wagonType).toBe('MIXED'); + // Mixed freight now labels the summary by the concrete wagon type codes it uses. + expect(result.summary.wagonType).toContain('NW5'); + expect(result.summary.wagonType).toContain('CW3'); expect(result.wagonPlan.length).toBeGreaterThan(2); expect(result.containerUnits).toHaveLength(2); }); @@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => { }); it('rejects create when the locked locomotive is no longer available', async () => { + // Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other + // non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning. const manager = { getRepository: jest.fn(() => ({ - findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), + findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }), })), }; @@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => { originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, + status: 'AVAILABLE', + direction: 'IMPORT', }), }; } @@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => { }); describe('getAvailableLocomotivesForRoute', () => { - it('returns locomotives at the route origin yard', async () => { + it('returns every in-service locomotive, annotated with origin-yard presence', async () => { const routeId = 'route-export'; const originYardId = 'yard-addis'; const routeRepo = { @@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => { id: routeId, name: 'Addis → Djibouti', isActive: true, + status: 'AVAILABLE', originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Djibouti' }, @@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => { if ((entity as { name?: string })?.name === 'Route') return routeRepo; return { findOne: jest.fn(), update: jest.fn() }; }); + // Advance-scheduling picker: nothing is filtered by yard — every in-service + // locomotive is returned and annotated with whether it's at the origin yet. locomotivesRepository.findAll.mockResolvedValue([ { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, + { id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' }, ]); const result = await service.getAvailableLocomotivesForRoute(routeId); - expect(locomotivesRepository.findAll).toHaveBeenCalledWith({ - where: { status: 'AVAILABLE', currentYardId: originYardId }, - order: { code: 'ASC' }, - }); - expect(result).toHaveLength(1); - expect(result[0].code).toBe('EXP'); + expect(result).toHaveLength(2); + expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true); + expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false); }); - it('returns all locomotives returned by the repository for domestic routes', async () => { + it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => { const routeId = 'route-domestic'; const originYardId = 'yard-addis'; const routeRepo = { @@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => { id: routeId, name: 'Addis → Dire Dawa', isActive: true, + status: 'AVAILABLE', originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Ethiopia' }, @@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => { if ((entity as { name?: string })?.name === 'Route') return routeRepo; return { findOne: jest.fn(), update: jest.fn() }; }); - locomotivesRepository.findAll.mockResolvedValue([ - { id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId }, - { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, - ]); - const result = await service.getAvailableLocomotivesForRoute(routeId); - - expect(result).toHaveLength(2); + await expect( + service.getAvailableLocomotivesForRoute(routeId), + ).rejects.toBeInstanceOf(BadRequestException); }); }); @@ -1081,4 +1122,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 93d8bba56..4a26236b0 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}`]), @@ -2780,13 +2811,12 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); - const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; @@ -2795,8 +2825,6 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} - ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -2878,8 +2906,6 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity - Customer Name - Customer ID Cargo Type Container No Chassis No @@ -2887,7 +2913,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} @@ -3852,25 +3878,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)'); } @@ -3956,7 +3981,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; @@ -4018,10 +4058,11 @@ export class TrainSchedulingService { ).values(), ]; pushLimit( - validateMixedTrainLimits( + validateMixedTrainLimitsPerEdge( wagonPlan, plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], trainLimits, + stops, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -6810,12 +6851,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. @@ -6824,6 +6886,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(); @@ -7232,6 +7330,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/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/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 96122d9bd..8e9e0bc76 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, ) {} /** @@ -508,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, @@ -525,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', @@ -953,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 } : {}), @@ -967,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 ? [ @@ -988,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 }, @@ -1514,6 +1578,14 @@ export class WarehouseInventoryService { notes: `Bulk received (${dto.direction})`, truckEntrance, }); + + // Validate capacity before saving + const weight = Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); + this.assertCapacity('Yard', yard, weight, 0, containerCount); + this.assertCapacity('Zone', zone, weight, 0, containerCount); + const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1521,7 +1593,7 @@ export class WarehouseInventoryService { zoneId: dto.zoneId, bookingId, quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight: Number(booking.weight) || 0, + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1529,6 +1601,9 @@ export class WarehouseInventoryService { }), ); + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + // Receiving the booking flags every container unit as received into the // port (self-haul export: the delivering truck's goods are now in) so // staff can raise the per-container GRN over what's received. @@ -2947,6 +3022,29 @@ export class WarehouseInventoryService { ); } + // A truck leaves with the containers ASSIGNED to it — never another + // truck's. Enforced whenever the truck has an assigned load on file + // (customer self-haul or EDR last-mile). + if (dto.containerNumber && dto.truckPlateNumber?.trim()) { + const selectedNumbers = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim().toUpperCase()) + .filter(Boolean); + const assigned = await this.truckAssignedContainers( + item.bookingId, + dto.truckPlateNumber.trim(), + ); + if (assigned.length && selectedNumbers.length) { + const foreign = selectedNumbers.filter((n) => !assigned.includes(n)); + if (foreign.length) { + throw new BadRequestException( + `Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` + + `not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`, + ); + } + } + } + // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. // Skipped when the operator chose not to weigh (containers only). @@ -2972,12 +3070,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 @@ -2990,12 +3105,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). @@ -3058,7 +3207,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()), @@ -3072,7 +3221,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(), @@ -3080,7 +3230,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 @@ -3130,11 +3304,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 @@ -3175,6 +3381,17 @@ export class WarehouseInventoryService { } } + /** + * customer_truck_assignments.gross_weight_kg holds TONNES for gate-out + * recorded exits but real KG for legacy departTruck rows. Exit papers always + * print tonnes — normalise on read. + */ + // ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg); + // migrate the column to tonnes if it ever bites. + private grossAsTons(value: number): number { + return value > 1000 ? Math.round(value) / 1000 : value; + } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3234,7 +3451,40 @@ export class WarehouseInventoryService { grossWeightKg: string | number | null; departedAt: string | null; } | null = null; - if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + // The exit-inspection note written at gate-out names the truck doing THIS + // exit — resolve by its plate first. The item's own container may not be on + // the departing truck at all (trucks pick containers freely per trip). + const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)]; + const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null; + if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = a.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at + LIMIT 1`, + [row.bookingId, exitPlate], + ); + truck = truckRow ?? null; + } + if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", @@ -3264,6 +3514,26 @@ export class WarehouseInventoryService { ); truck = truckRow ?? null; } + // Bulk self-haul (no container to match) or an unmatched container: the exit + // paper is still PER TRUCK — use the latest departed customer truck and its + // weighed gross, never the booking's declared total. + if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + NULL AS "containerNumbers", + a.net_weight_tons AS "truckWeightTons" + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL + ORDER BY a.departed_at DESC + LIMIT 1`, + [row.bookingId], + ); + truck = truckRow ?? null; + } const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -3278,7 +3548,9 @@ export class WarehouseInventoryService { customerName: row?.customerName ?? null, freightType: row?.freightType ?? null, tradeDirection: row?.tradeDirection ?? null, - containerNumber: row?.containerNumber ?? null, + // Per-truck exit: list every container leaving on THIS truck, not just + // the inventory item's own container. + containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, quantity: Number(row?.quantity ?? 0), weight: Number(row?.weight ?? 0), @@ -3292,12 +3564,12 @@ export class WarehouseInventoryService { truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, truckGateOut: truck?.departedAt ?? null, - // Prefer the weighed gross captured on departure; fall back to the summed - // container VGM when the truck hasn't been weighed yet. - truckWeightKg: truck - ? Number(truck.grossWeightKg ?? 0) > 0 - ? Number(truck.grossWeightKg) - : Number(truck.truckWeightTons ?? 0) * 1000 + // Per-truck load in tonnes: the summed VGM of the containers on this truck + // (recorded net for bulk); the weighed gross only as fallback. + truckWeightTons: truck + ? Number(truck.truckWeightTons ?? 0) > 0 + ? Number(truck.truckWeightTons) + : this.grossAsTons(Number(truck.grossWeightKg ?? 0)) : null, }); @@ -3415,6 +3687,32 @@ export class WarehouseInventoryService { })); } + /** + * Container numbers assigned to a truck (by plate) on this booking, from both + * haulage paths: customer self-haul (customer_truck_containers) and EDR + * last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased. + */ + private async truckAssignedContainers(bookingId: string, plate: string): Promise { + const rows: Array<{ cn: string | null }> = await this.dataSource.query( + `SELECT UPPER(cc.container_number) AS cn + FROM freight.customer_truck_assignments a + JOIN freight.customer_truck_containers cc + ON cc.assignment_id = a.id AND cc.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + UNION + SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn + 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.vehicles v ON v.id = va.vehicle_id + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`, + [bookingId, plate], + ); + return rows.map((r) => r.cn).filter((n): n is string => Boolean(n)); + } + /** * The booking's containers with their VGM cargo weight (tonnes), keyed by * container number. Drives the truck-leaving exit weighing: the selected @@ -3470,10 +3768,17 @@ export class WarehouseInventoryService { ); if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); - const containers: Array<{ containerNumber: string; goods: string | null }> = + const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> = await this.dataSource.query( `SELECT c.container_number AS "containerNumber", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + (SELECT SUM(u.vgm_tons) + FROM freight.booking_container_units u + JOIN freight.booking_container bc + ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL + WHERE u.container_number = c.container_number + AND bc.booking_id = c.booking_id + AND u.deleted_at IS NULL) AS "vgmTons" FROM freight.customer_truck_containers c JOIN freight.bookings b ON b.id = c.booking_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -3482,6 +3787,9 @@ export class WarehouseInventoryService { [assignmentId], ); + // Truck load in tonnes: summed container VGM; the weighed gross only as + // fallback (bulk trucks carry no containers). + const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0); const html = this.buildTruckExitPaperHtml({ reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, bookingReference: truck.bookingReference, @@ -3489,7 +3797,7 @@ export class WarehouseInventoryService { plateNumber: truck.plateNumber, driverName: truck.driverName, truckType: truck.truckType, - grossWeightKg: Number(truck.grossWeightKg ?? 0), + grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)), gateOut: truck.departedAt, containers, }); @@ -3816,8 +4124,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 @@ -3828,7 +4341,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). */ @@ -3856,12 +4391,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", @@ -3913,6 +4458,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 @@ -3931,12 +4486,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)), }); @@ -3970,7 +4527,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 { @@ -4009,14 +4575,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)`, + ); + } } } @@ -4112,6 +4725,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); } @@ -4685,7 +5304,7 @@ export class WarehouseInventoryService { truckDriverName?: string | null; truckType?: string | null; truckGateOut?: string | null; - truckWeightKg?: number | null; + truckWeightTons?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -4712,8 +5331,8 @@ export class WarehouseInventoryService { ['Quantity', data.quantity], [ data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', - `${(data.truckPlateNumber && data.truckWeightKg - ? data.truckWeightKg + `${(data.truckPlateNumber && data.truckWeightTons + ? data.truckWeightTons : data.weight ).toLocaleString()} t`, ], @@ -4839,10 +5458,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) => @@ -4888,6 +5508,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; @@ -5297,7 +5918,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, ]; @@ -5305,12 +5930,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, @@ -5324,25 +5951,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 { 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/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 3279e9092..5b5e2b227 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -44,6 +44,7 @@ export class WarehouseYardsService { // Ensure the parent warehouse exists. await this.warehousesService.findById(warehouseId); await this.assertCodeUnique(warehouseId, dto.code.trim()); + await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.yardsRepository.create({ warehouseId, @@ -69,14 +70,22 @@ export class WarehouseYardsService { await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed warehouse limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.yardsRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -97,4 +106,39 @@ export class WarehouseYardsService { throw new ConflictException(`Yard code ${code} already exists in this warehouse`); } } + + private async assertCapacityWithinWarehouse( + warehouseId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeYardId?: string, + ): Promise { + const warehouse = await this.warehousesService.findById(warehouseId); + const yards = await this.findByWarehouse(warehouseId); + + // Sum existing yard capacities, excluding the yard being updated if provided + const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards; + const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0); + const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && warehouse.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > warehouse.capacityWeight) { + throw new BadRequestException( + `Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && warehouse.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > warehouse.capacityContainers) { + throw new BadRequestException( + `Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index b4ae2e0de..367a5a75e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; @@ -43,6 +43,7 @@ export class WarehouseZonesService { // Ensure the parent yard exists. await this.yardsService.findById(yardId); await this.assertCodeUnique(yardId, dto.code.trim()); + await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.zonesRepository.create({ yardId, @@ -68,14 +69,22 @@ export class WarehouseZonesService { await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed yard limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.zonesRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -96,4 +105,39 @@ export class WarehouseZonesService { throw new ConflictException(`Zone code ${code} already exists in this yard`); } } + + private async assertCapacityWithinYard( + yardId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeZoneId?: string, + ): Promise { + const yard = await this.yardsService.findById(yardId); + const zones = await this.findByYard(yardId); + + // Sum existing zone capacities, excluding the zone being updated if provided + const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones; + const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0); + const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && yard.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > yard.capacityWeight) { + throw new BadRequestException( + `Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && yard.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > yard.capacityContainers) { + throw new BadRequestException( + `Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e6ea89fbe..7ebcfb89c 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, + { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, + { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, ]; 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 396ac95db..741ceafb1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -804,6 +804,48 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, ]), + // Operations Chief: full operational authority — the entire freight + // permission catalog (all CRUD across bookings, contracts, scheduling, + // fleet, warehouse, mile, finance, settings, staff). + operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), + // Dispatcher: full CRUD on warehouse management (incl. import/export/intercity + // inventory flows) and fleet management, plus truck dispatch on the mile legs + // and operational context. The ONE carve-out: allocation & fee rules stay + // VIEW-ONLY — a dispatcher never creates/updates/deletes those rules. + dispatcher: dedupe([ + // Warehouse management — full CRUD. + FREIGHT_PERMS.warehouseDashboard.view, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), + // View-only on the rules that govern allocation and fees. + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + // Fleet management — full CRUD. + ...Object.values(FREIGHT_PERMS.fleet), + FREIGHT_PERMS.fleetDashboard.view, + ...Object.values(FREIGHT_PERMS.fleetReports), + ...Object.values(FREIGHT_PERMS.vehicles), + ...Object.values(FREIGHT_PERMS.drivers), + ...Object.values(FREIGHT_PERMS.tracking), + ...Object.values(FREIGHT_PERMS.fuel), + ...Object.values(FREIGHT_PERMS.maintenance), + ...Object.values(FREIGHT_PERMS.locomotives), + ...Object.values(FREIGHT_PERMS.wagons), + ...Object.values(FREIGHT_PERMS.trains), + ...Object.values(FREIGHT_PERMS.routes), + ...Object.values(FREIGHT_PERMS.containers), + ...Object.values(FREIGHT_PERMS.cargoes), + // Truck dispatch on the EDR mile legs + operational context. + ...Object.values(FREIGHT_PERMS.firstMile), + ...Object.values(FREIGHT_PERMS.lastMile), + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.bookings.operations, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 86f5bf6e6..d216447f3 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -146,11 +146,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Overview", href: "/dashboard/overview", icon: , + permission: FREIGHT_PERMS.overview.view, }, { label: "Customers", href: "/dashboard/customers", icon: , + permission: FREIGHT_PERMS.customers.view, }, { label: "Contracts", @@ -162,6 +164,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). @@ -187,6 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Support", href: "/dashboard/support", icon: , + permission: FREIGHT_PERMS.support.view, }, ...demoItems, ], @@ -375,31 +379,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Imports", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Import Overview", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Arrival Queue", href: "/dashboard/arrival-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -407,41 +417,49 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Exports", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Export Overview", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loading Queue", href: "/dashboard/loading-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loaded Inventory", href: "/dashboard/loaded-inventory", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Djibouti Unloading", href: "/dashboard/export-djibouti-unloading", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Interchange Documents", href: "/dashboard/interchange-documents", icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -449,11 +467,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Intercity", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, children: [ { label: "Intercity Cargo", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -465,6 +485,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 +493,28 @@ 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, + FREIGHT_PERMS.warehouseFeeRules.view, + ], }, { label: "Fee Invoices", href: "/dashboard/warehouse-fee-invoices", icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, }, ], }, @@ -523,10 +551,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 +571,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Staff", href: "/user-management", icon: , + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ], }, @@ -585,21 +621,32 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; - const itemAllowed = (item: SidebarItem): boolean => { - // GL positions are locked to their single clearance page. - if (etGl) return isEtClearanceItem(item); - if (djGl) return isDjClearanceItem(item); - - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; - - return permissionAllowed(item); - }; + // Recursive: children are filtered first; a group (item with children) stays + // only while it still has visible children — so parents without their own + // permission key never leak a whole subtree the user cannot open. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items + .map((item) => + item.children ? { ...item, children: filterItems(item.children) } : item, + ) + .filter((item) => { + if (etGl || djGl) { + // GL positions are locked to their single clearance page (parents + // survive only as the path to that page). + const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; + return isTarget(item) || (item.children?.length ?? 0) > 0; + } + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + if (!permissionAllowed(item)) return false; + if (item.children) return item.children.length > 0; + return true; + }); return sections .map((section) => ({ ...section, - items: section.items.filter(itemAllowed), + items: filterItems(section.items), })) .filter((section) => section.items.length > 0); }; @@ -708,7 +755,7 @@ const App = () => { } /> {/* } /> */} } /> - } /> + } /> } /> ); 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/cargoes/DeliverCargoDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx index 546bd0def..6591ffa7c 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/DeliverCargoDialog.tsx @@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea'; import { useMutation } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; +import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate'; /** * Customer Pickup + Proof of Delivery capture for a LOADED cargo. @@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on toast({ title: 'Receiver name is required', variant: 'destructive' }); return; } + if (isBackdated(pickupDate)) { + toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' }); + return; + } try { await deliver.mutateAsync({ id: cargoId, @@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on setPickupDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx index 35a75d66c..088f65ba9 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx @@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [ { value: "CONTAINER_OPENED", label: "Container opened" }, { value: "CONTAINER_DAMAGED", label: "Container damaged" }, { value: "FLUID_LEAKING", label: "Fluid leaking" }, + { value: "OTHER", label: "Other" }, ]; const LABEL: Record = { @@ -30,6 +31,7 @@ const LABEL: Record = { CONTAINER_OPENED: "Container opened", CONTAINER_DAMAGED: "Container damaged", FLUID_LEAKING: "Fluid leaking", + OTHER: "Other", }; export function IncidentReportCard({ bookingId }: { bookingId: string }) { diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx index 2175d4d7b..f0e9b266a 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ResetPasswordAction.tsx @@ -61,8 +61,11 @@ export default function ResetPasswordAction({ if (!allowed) return null; + // SMS is domestic-only: a foreign number counts as unavailable, same as a + // missing one, so staff can't send a link that will never arrive. + const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false; const channelMissing = - !!target && (channel === "email" ? !target.email : !target.phone); + !!target && (channel === "email" ? !target.email : !phoneUsable); return ( <> @@ -106,9 +109,13 @@ export default function ResetPasswordAction({ (null); const [note, setNote] = useState(""); const act = (next: ProfileStatus) => mutate({ profileId, status: next }); - const confirmReject = () => { + // Decisions the customer must be given a reason for. Reject/suspend/reactivate + // all capture a required message through the same modal; the API refuses + // suspend/reactivate without one. + const DECISIONS = { + reject: { + title: "Reject profile", + intro: + "Tell the customer what needs fixing. They'll see this note and can " + + "amend and resubmit the role for approval.", + label: "Reason for rejection", + placeholder: "e.g. The uploaded business license is expired.", + confirmLabel: "Reject profile", + color: "red", + status: "rejected" as ProfileStatus, + }, + suspend: { + title: "Suspend role", + intro: + "Explain why this role is being suspended. The customer will see this " + + "message and cannot operate under the role until it is reactivated.", + label: "Reason for suspension", + placeholder: "e.g. Outstanding invoices unpaid for over 90 days.", + confirmLabel: "Suspend role", + color: "orange", + status: "suspended" as ProfileStatus, + }, + reactivate: { + title: "Reactivate role", + intro: + "Explain why this role is being reactivated. The customer will see " + + "this message and can operate under the role again.", + label: "Reactivation message", + placeholder: "e.g. Outstanding payments have been settled.", + confirmLabel: "Reactivate role", + color: "edr-green", + status: "active" as ProfileStatus, + }, + } as const; + + const openDecision = (kind: keyof typeof DECISIONS) => { + setNote(""); + setDecision(kind); + }; + + const active = decision ? DECISIONS[decision] : null; + + const confirmDecision = () => { + if (!active) return; mutate( - { profileId, status: "rejected", note: note.trim() }, - { onSuccess: () => setRejectOpen(false) }, + { profileId, status: active.status, note: note.trim() }, + { onSuccess: () => setDecision(null) }, ); }; - const rejectModal = ( + const decisionModal = active && ( setRejectOpen(false)} - title="Reject profile" + opened + onClose={() => setDecision(null)} + title={active.title} centered radius="lg" > - Tell the customer what needs fixing. They'll see this note and can - amend and resubmit the role for approval. + {active.intro} -

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..dda8c1703 --- /dev/null +++ b/e2e/freight/cypress.config.ts @@ -0,0 +1,99 @@ +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. + */ + /** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */ + async "db:seedFile"(file: string) { + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + const sql = readFileSync( + join(process.cwd(), "cypress", "fixtures", file), + "utf8", + ); + await client.query(sql); + return true; + } finally { + await client.end(); + } + }, + + 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/export_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts new file mode 100644 index 000000000..8e7c6334d --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/export_one_time.cy.ts @@ -0,0 +1,524 @@ +/** + * Export ONE_TIME journeys — two contracts ride the same export train + * (Mojo Dry Port → Dire Dawa Yard → Djibouti Port Terminal): + * + * A. CONTAINER (20ft + 40ft): + * portal wizard → staff approval chain → OTP sign → counter-sign + * → AWAITING_CLEARANCE_DOCUMENTS (export self-clear has no required + * docs in e2e) → ops finalize → FULLY_EXECUTED → customer books + * 2 × 20ft + 1 × 40ft picking a real Shipment day → ops accepts the + * operation request → EXPORT is FCFS, so accept reserves the train slot + * immediately: SELECTED_FOR_BATCH with a pay deadline clamped to the + * export window close → staff mark-paid → PAID + SCHEDULED + linked. + * + * B. BULK (E2E Wheat, 60 tons): same journey through the bulk wizard and + * bulk booking form, riding CW4 covered wagons on the same train. + * + * Infrastructure (route, built train, distances, rates, cargo types) comes + * from seed-intercity.sql + seed-export.sql; the export route and the + * departing-today schedule are created through the UI when missing. + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql +const opsStaff = "operation@edr.local"; + +const ORIGIN_YARD = "Mojo Dry Port"; +const MID_YARD = "Dire Dawa Yard"; +const PORT_YARD = "Djibouti Port Terminal"; +const TRAIN_CODE = "TRN-E2E-1"; + +// Container numbers must be ISO (4 letters + 7 digits) and unused — stamp per run. +const stamp = String(Date.now()); +const isoNumber = (prefix: string, offset: number) => + `${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`; + +const apiUrl = () => Cypress.env("apiUrl") as string; + +function dbContract(freight: "CONTAINER" | "BULK") { + return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>( + "db:query", + { + sql: `SELECT ct.id, ct.reference, ct.status + FROM freight.contracts ct + JOIN freight.companies c ON c.id = ct.company_id + WHERE c.tin = $1 AND ct.trade_direction = 'EXPORT' AND ct.freight_type = $2 + ORDER BY ct.created_at DESC LIMIT 1`, + params: [companyTin, freight], + }, + ); +} + +function withContract( + freight: "CONTAINER" | "BULK", + fn: (c: { id: string; reference: string; status: string }) => void, +) { + dbContract(freight).then(({ rows }) => { + expect(rows, `latest EXPORT ${freight} contract`).to.have.length(1); + fn(rows[0]); + }); +} + +function expectContractStatus(freight: "CONTAINER" | "BULK", expected: string) { + dbContract(freight).then(({ rows }) => { + expect(rows[0]?.status, "contract status").to.eq(expected); + }); +} + +function dbBooking(freight: "CONTAINER" | "BULK") { + return cy.task<{ + rows: Array<{ + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }>; + }>("db:query", { + sql: `SELECT b.id, b.reference, b.status, b.scheduling_status, + b.train_schedule_id, b.payment_deadline, b.scheduled_date + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + WHERE c.tin = $1 AND b.trade_direction = 'EXPORT' AND b.freight_type = $2 + ORDER BY b.created_at DESC LIMIT 1`, + params: [companyTin, freight], + }); +} + +function withBooking( + freight: "CONTAINER" | "BULK", + fn: (b: { + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }) => void, +) { + dbBooking(freight).then(({ rows }) => { + expect(rows, `EXPORT ${freight} booking`).to.have.length(1); + fn(rows[0]); + }); +} + +/** + * The journey's export schedule. Export trains must be scheduled ≥ the booking + * lead (24h) ahead, and their window OPENS at departure − lead — so the spec + * departs at now + 24h + a couple of minutes: creatable now, window opens + * minutes later. (The intercity spec's train leaves in 2 days — outside 25h.) + */ +function dbUpcomingSchedule() { + return cy.task<{ + rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>; + }>("db:query", { + sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status + FROM freight.train_schedules ts + WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL + AND ts.scheduled_departure_date > now() + AND ts.scheduled_departure_date < now() + interval '25 hours' + ORDER BY ts.created_at DESC LIMIT 1`, + }); +} + +/** The train departs ~24h out — bookings ride its departure day (tomorrow). */ +const SHIPMENT_DAY = new Date(Date.now() + 24 * 3_600_000 + 150_000); + +function fill(label: string | RegExp, value: string) { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */ +function fillNth(label: RegExp, index: number, value: string) { + cy.get("label").then(($labels) => { + const matches = $labels.filter((_, el) => label.test(el.textContent ?? "")); + expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index); + const id = matches.eq(index).attr("for"); + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** + * Choose the train's departure day on the Schedule card's INLINE calendar + * (cargo-aware: days only unlock once the cargo details above are valid). + */ +function pickShipmentDay() { + cy.contains(/available day/, { timeout: 30000 }).should("exist"); + // Day cells are plain buttons in a div grid; only bookable days are enabled + // (out-of-month duplicates stay disabled). + const day = String(SHIPMENT_DAY.getDate()); + cy.get("button:not(:disabled)", { timeout: 15000 }) + .contains(new RegExp(`^${day}$`)) + .click({ force: true }); +} + +/** Shared staff steps: accept + LINE_STAFF approve, then director approve. */ +function approveChain(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice("marketer@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains("button", "Accept for approval", { timeout: 20000 }).click(); + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + cy.loginBackoffice("director@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + expectContractStatus(freight, "CONTRACT_READY"); +} + +/** Shared customer OTP-signature step. */ +function customerSigns(freight: "CONTAINER" | "BULK") { + cy.loginPortal(customer); + withContract(freight, (c) => cy.visitPortal(`/contracts/${c.id}/view`)); + + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + const el = win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "SIGNED_CUSTOMER"); +} + +/** Shared counter-sign + ops finalize (export self-clear: no required docs in e2e). */ +function counterSignAndFinalize(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice("marketer@edr.local"); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`)); + cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); + }); + cy.drawSignature(); + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS"); + + cy.loginBackoffice(opsStaff); + withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click(); + cy.contains("button", "Finalize document approval", { timeout: 30000 }) + .should("not.be.disabled") + .click(); + cy.contains("finalized", { timeout: 30000 }).should("be.visible"); + expectContractStatus(freight, "FULLY_EXECUTED"); +} + +/** Ops accept: EXPORT is FCFS — accept reserves the slot and opens the pay window. */ +function acceptAndAssertReserved(freight: "CONTAINER" | "BULK") { + cy.loginBackoffice(opsStaff); + withBooking(freight, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`)); + cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click(); + cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click(); + cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist"); + + dbUpcomingSchedule().then(({ rows: schedules }) => { + expect(schedules, "departing-today export schedule").to.have.length(1); + withBooking(freight, (b) => { + expect(b.status, "FCFS reservation").to.eq("SELECTED_FOR_BATCH"); + expect(b.train_schedule_id).to.eq(schedules[0].id); + // Export parity: the pay window never outlives the booking window close. + expect(b.payment_deadline, "pay deadline set").to.be.a("string"); + expect(new Date(b.payment_deadline!).getTime()).to.be.at.most( + new Date(schedules[0].window_closes_at).getTime(), + ); + }); + }); +} + +function markPaidAndAssertAllocated(freight: "CONTAINER" | "BULK") { + withBooking(freight, (b) => { + cy.apiLogin(opsStaff).then(({ token }) => { + cy.request({ + method: "POST", + url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + }); + withBooking(freight, (b) => { + expect(b.status).to.eq("PAID"); + expect(b.scheduling_status).to.eq("SCHEDULED"); + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND deleted_at IS NULL`, + params: [b.id], + }).then(({ rows }) => { + expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1); + }); + }); +} + +describe("export one-time journeys: container + bulk on one train", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-intercity.sql"); + cy.task("db:seedFile", "seed-export.sql"); + }); + + // ── Shared infrastructure ───────────────────────────────────────────────── + + it("operations ensures the export route exists", () => { + cy.loginBackoffice(opsStaff); + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/routes"); + cy.contains("button", "Add route", { timeout: 20000 }).click(); + cy.contains("Add Route", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", "Add milestone").click(); + const pickYard = (index: number, yard: string) => { + cy.get('.mantine-Modal-content input[placeholder="Select yard"]') + .eq(index) + .click({ force: true }); + cy.get('[role="option"]:visible').contains(yard).click(); + }; + pickYard(0, ORIGIN_YARD); + pickYard(1, MID_YARD); + pickYard(2, PORT_YARD); + cy.get(".mantine-Modal-content").contains("button", "Save").click(); + }); + }); + + it("operations schedules the export train — booking window opens", () => { + cy.loginBackoffice(opsStaff); + + dbUpcomingSchedule().then(({ rows }) => { + if (rows.length > 0) return; + + cy.visit("/dashboard/operations/train-scheduling-v2"); + cy.contains("button", "New schedule", { timeout: 20000 }).click(); + cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible"); + cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD)); + + // Just past the 24h scheduling lead: creatable now, and the export + // window (opens departure − lead) flips OPEN a couple of minutes later. + const local = new Date( + SHIPMENT_DAY.getTime() - SHIPMENT_DAY.getTimezoneOffset() * 60000, + ) + .toISOString() + .slice(0, 16); + cy.get('.mantine-Modal-content input[type="datetime-local"]') + .clear({ force: true }) + .type(local, { force: true }); + cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE)); + cy.get(".mantine-Modal-content").contains("button", "Create").click(); + cy.location("pathname", { timeout: 30000 }).should( + "match", + /\/dashboard\/operations\/train-scheduling-v2\/.+/, + ); + }); + + // The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes. + const waitForOpenWindow = (attempt: number) => { + dbUpcomingSchedule().then(({ rows }) => { + expect(rows, "upcoming export schedule").to.have.length(1); + if (rows[0].booking_window_status === "OPEN") return; + expect(attempt, "export booking window OPEN").to.be.lessThan(40); + cy.wait(10000).then(() => waitForOpenWindow(attempt + 1)); + }); + }; + waitForOpenWindow(0); + }); + + // ── Journey A: container 20ft + 40ft ────────────────────────────────────── + + it("customer submits an export container contract (20ft + 40ft)", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + cy.mantineSelect(/^Operation Type/, /^Export$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('[role="checkbox"][aria-label="40ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics"); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, PORT_YARD); + cy.contains("button", "Continue").click({ force: true }); + + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + expectContractStatus("CONTAINER", "SUBMITTED"); + }); + + it("staff approve the container contract (marketer + director)", () => { + approveChain("CONTAINER"); + }); + + it("customer signs the container contract with OTP", () => { + customerSigns("CONTAINER"); + }); + + it("staff counter-sign and operations finalize the container contract", () => { + counterSignAndFinalize("CONTAINER"); + }); + + it("customer books 2 × 20ft + 1 × 40ft with a shipment day", () => { + cy.loginPortal(customer); + withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + // Two size editors, each with its own "Quantity *" (20ft first, then 40ft). + fillNth(/^Quantity/, 0, "2"); + fillNth(/^Quantity/, 1, "1"); + + cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3); + cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0)); + cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", 1)); + cy.get('input[placeholder*="MSCU"]').eq(2).type(isoNumber("FSCU", 2)); + + cy.get('input[placeholder*="24.5"]').each(($input) => { + cy.wrap($input).clear({ force: true }).type("10", { force: true }); + }); + + pickShipmentDay(); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking("CONTAINER", (b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string"); + }); + }); + + it("operations accepts the container request — FCFS reserves today's train", () => { + acceptAndAssertReserved("CONTAINER"); + }); + + it("staff mark the container booking paid — allocated onto the train", () => { + markPaidAndAssertAllocated("CONTAINER"); + }); + + // ── Journey B: bulk (E2E Wheat) ─────────────────────────────────────────── + + it("customer submits an export bulk contract (wheat)", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + cy.mantineSelect(/^Operation Type/, /^Export$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true }); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/); + cy.mantineSelect(/^Bulk Cargo Type/, "E2E Grains"); + cy.mantineSelect(/^Commodity/, "E2E Wheat"); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, PORT_YARD); + cy.contains("button", "Continue").click({ force: true }); + + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Approve & submit").click(); + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + + expectContractStatus("BULK", "SUBMITTED"); + }); + + it("staff approve the bulk contract (marketer + director)", () => { + approveChain("BULK"); + }); + + it("customer signs the bulk contract with OTP", () => { + customerSigns("BULK"); + }); + + it("staff counter-sign and operations finalize the bulk contract", () => { + counterSignAndFinalize("BULK"); + }); + + it("customer books 60 tons of wheat with a shipment day", () => { + cy.loginPortal(customer); + withContract("BULK", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + fill(/^Quantity \(tons\)/, "60"); + pickShipmentDay(); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking("BULK", (b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + }); + }); + + it("operations accepts the bulk request — FCFS reserves today's train", () => { + acceptAndAssertReserved("BULK"); + }); + + it("staff mark the bulk booking paid — allocated onto the train", () => { + markPaidAndAssertAllocated("BULK"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts new file mode 100644 index 000000000..b8ca2fd1b --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts @@ -0,0 +1,574 @@ +/** + * Intercity ONE_TIME journey — the full life of a domestic ride-along shipment: + * + * 1. portal — customer creates an INTERCITY / One-Time / Container contract + * (Mojo Dry Port → Dire Dawa Yard) and submits it + * 2. backoffice — marketer REJECTS it with a reason + * 3. portal — customer sees the rejection banner + reason, then submits a + * fresh contract + * 4. backoffice — marketer accepts + approves LINE_STAFF, director approves + * → CONTRACT_READY + * 5. portal — customer OTP-signs → SIGNED_CUSTOMER + * 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS + * (ONE_TIME intercity always routes through the + * intercity-documents step; the fixture seeds one REQUIRED + * document so the step is real) + * 6b. portal — customer uploads the required intercity document + * → CLEARANCE_UNDER_REVIEW + * 7. backoffice — operations approves the document, then finalizes document + * approval → FULLY_EXECUTED + * 8. portal — customer books 2 × 20ft under the contract (intercity has + * no shipment date) → booking OPERATION_REQUEST_PENDING + * 9. backoffice — operations accepts the operation request → booking + * FULLY_EXECUTED (intercity waiting pool) + * 10. backoffice — operations creates the EXPORT route + * Mojo → Dire Dawa → Djibouti Port (distances seeded) + * 11. backoffice — operations schedules the export train (built Train-Builder + * train seeded by seed-intercity.sql) + * 12. backoffice — operations accepts the intercity booking onto the train + * (Workspace → Intercity ride-along) → SELECTED_FOR_BATCH with + * a pay deadline that never outlives the export window close + * 13. staff mark-paid (API — the batch panel has no mounted UI button) + * → PAID + SCHEDULED + linked to the schedule + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql +const opsStaff = "operation@edr.local"; + +const ORIGIN_YARD = "Mojo Dry Port"; +const DEST_YARD = "Dire Dawa Yard"; +const PORT_YARD = "Djibouti Port Terminal"; +const TRAIN_CODE = "TRN-E2E-1"; + +const apiUrl = () => Cypress.env("apiUrl") as string; + +/** Latest contract of the seeded company — the journey's contract. */ +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 AND ct.trade_direction = 'DOMESTIC' + 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 expectContractStatus(expected: string) { + dbContract().then(({ rows }) => { + expect(rows[0]?.status, "contract status").to.eq(expected); + }); +} + +/** Latest DOMESTIC booking of the seeded company — the journey's booking. */ +function dbBooking() { + return cy.task<{ + rows: Array<{ + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }>; + }>("db:query", { + sql: `SELECT b.id, b.reference, b.status, b.scheduling_status, + b.train_schedule_id, b.payment_deadline, b.scheduled_date + FROM freight.bookings b + JOIN freight.companies c ON c.id = b.company_id + WHERE c.tin = $1 AND b.trade_direction = 'DOMESTIC' + ORDER BY b.created_at DESC LIMIT 1`, + params: [companyTin], + }); +} + +function withBooking( + fn: (b: { + id: string; + reference: string; + status: string; + scheduling_status: string; + train_schedule_id: string | null; + payment_deadline: string | null; + scheduled_date: string | null; + }) => void, +) { + dbBooking().then(({ rows }) => { + expect(rows, "intercity booking for the seeded company").to.have.length(1); + fn(rows[0]); + }); +} + +/** Latest export schedule created by this journey. */ +function dbSchedule() { + return cy.task<{ + rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>; + }>("db:query", { + sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at + FROM freight.train_schedules ts + ORDER BY ts.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 }); + }); +} + +/** + * Run the portal wizard for an INTERCITY / One-Time / Container contract and + * submit it. Reused for the initial (to-be-rejected) and the second contract. + */ +function createIntercityContract() { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + // Step 0 — Setup. Intercity forces ETB and hides the customs section. + cy.mantineSelect(/^Operation Type/, /^Intercity$/); + cy.mantineSelect(/^Contract Kind/, "One-Time Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click(); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + // Step 1 — Cargo & Route (Ethiopian yards only for intercity). + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type( + "E2E intercity electronics between Ethiopian yards", + ); + cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD); + cy.mantineSelect(/^Destination Yard/, DEST_YARD); + 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"); + + 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-/); + }); +} + +describe("intercity one-time journey: contract → booking → export train", { retries: 0 }, () => { + before(() => { + // Container types, locomotives, built train, yard distances — the + // infrastructure the UI journey cannot create in-flow. + cy.task("db:seedFile", "seed-intercity.sql"); + }); + + // ── Contract: submit → reject → resubmit → approve → sign ──────────────── + + it("customer submits an intercity one-time contract", () => { + createIntercityContract(); + }); + + it("marketer rejects the submission with a reason", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Reject contract", { timeout: 20000 }).click(); + cy.get(".mantine-Modal-content") + .contains("label", "Reason for rejection") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).type("E2E rejection — cargo details incomplete"); + }); + cy.get(".mantine-Modal-content").contains("button", /^Reject$/).click(); + + // Modal closes on success; the status pill can sit inside clipped layout, + // so the authoritative check is the DB row. + cy.get(".mantine-Modal-content", { timeout: 20000 }).should("not.exist"); + expectContractStatus("REJECTED"); + }); + + it("customer sees the rejection reason on the contracts list", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts"); + + // The list is a collapsed table — expand the rejected contract's row to + // reveal its step banner with the staff reason. + withContract((c) => { + cy.contains("tr", c.reference, { timeout: 20000 }) + .find("button") + .first() + .click(); + }); + cy.contains("This contract was rejected.", { timeout: 20000 }).should("be.visible"); + cy.contains("Reason: E2E rejection — cargo details incomplete").should("be.visible"); + }); + + it("customer submits a fresh intercity contract", () => { + createIntercityContract(); + }); + + 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(); + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + expectContractStatus("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(); + + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + expectContractStatus("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. + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + const el = win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + + cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible"); + expectContractStatus("SIGNED_CUSTOMER"); + }); + + it("marketer counter-signs — intercity one-time enters the documents step", () => { + 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(); + cy.get(".mantine-Modal-content") + .contains("button", /^Confirm signature$|^Approve & sign$/) + .click(); + + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + + // DOMESTIC one-time always routes through the intercity-documents step — + // unlike GENERAL, it does NOT go straight to CONTRACT_ACTIVE. + expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS"); + }); + + it("customer uploads the required intercity document", () => { + cy.loginPortal(customer); + // Deep link auto-opens the clearance documents modal. + withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`)); + + cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist"); + cy.get('.mantine-Modal-content input[type="file"]') + .first() + .selectFile("cypress/fixtures/docs/license.pdf", { force: true }); + cy.contains("button", "Submit documents", { timeout: 15000 }) + .should("not.be.disabled") + .click(); + + // Upload hands the contract to Operations review — the card flips to + // "Under review" (the host modal may linger while queries refetch). + cy.contains("Under review", { timeout: 30000 }).should("exist"); + expectContractStatus("CLEARANCE_UNDER_REVIEW"); + }); + + it("operations approves the document and finalizes — contract fully executed", () => { + cy.loginBackoffice(opsStaff); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + // The clearance review section lives behind its own tab on the detail page. + cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click(); + + // Approve the uploaded Cargo Manifest, then finalize. + cy.contains("button", /Approve all/, { timeout: 30000 }).click(); + cy.contains("1/1 approved", { timeout: 30000 }).should("exist"); + + cy.contains("button", "Finalize document approval", { timeout: 30000 }) + .should("not.be.disabled") + .click(); + + cy.contains("finalized", { timeout: 30000 }).should("be.visible"); + expectContractStatus("FULLY_EXECUTED"); + }); + + // ── Booking under the contract ──────────────────────────────────────────── + + it("customer books 2 × 20ft under the contract (no shipment date for intercity)", () => { + cy.loginPortal(customer); + withContract((c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`)); + + cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible"); + + // 20ft quantities must be even (pairs share a wagon). + fill(/^Quantity/, "2"); + + // One ISO container number per unit. + cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2); + cy.get('input[placeholder*="MSCU"]').eq(0).type("MSCU1234567"); + cy.get('input[placeholder*="MSCU"]').eq(1).type("TCLU7654321"); + + // VGM per unit — column inputs carry a placeholder, not a linked label. + cy.get('input[placeholder*="24.5"]').each(($input) => { + cy.wrap($input).clear({ force: true }).type("10", { force: true }); + }); + + // Intercity: no "Shipment day" picker — the ride-along note renders instead. + cy.contains("Shipment day").should("not.exist"); + + cy.contains("button", "Review price & book").should("not.be.disabled").click(); + cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible"); + cy.contains("button", "Confirm & book").click(); + + cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/); + + withBooking((b) => { + expect(b.status).to.eq("OPERATION_REQUEST_PENDING"); + expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null); + }); + }); + + it("operations accepts the operation request — booking joins the intercity pool", () => { + cy.loginBackoffice(opsStaff); + withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`)); + + cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click(); + cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible"); + cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click(); + + withBooking((b) => { + expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED"); + expect(b.train_schedule_id).to.eq(null); + }); + }); + + // ── Route + export schedule ─────────────────────────────────────────────── + + it("operations creates the export route Mojo → Dire Dawa → Djibouti Port", () => { + cy.loginBackoffice(opsStaff); + + // Skip creation when a previous run already added the route (unique yards + // pair) — the journey stays re-runnable against a warm DB. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/routes"); + cy.contains("button", "Add route", { timeout: 20000 }).click(); + cy.contains("Add Route", { timeout: 15000 }).should("be.visible"); + + // Third stop row, then fill Origin / Milestone / Destination in order. + cy.get(".mantine-Modal-content").contains("button", "Add milestone").click(); + const pickYard = (index: number, yard: string) => { + cy.get('.mantine-Modal-content input[placeholder="Select yard"]') + .eq(index) + .click({ force: true }); + // Three yard selects share option texts — only the open dropdown counts. + cy.get('[role="option"]:visible').contains(yard).click(); + }; + pickYard(0, ORIGIN_YARD); + pickYard(1, DEST_YARD); + pickYard(2, PORT_YARD); + + // Distances (when the build has them) resolve from the seeded rows. + cy.get(".mantine-Modal-content").contains("button", "Save").click(); + }); + + cy.task<{ rows: Array<{ direction: string }> }>("db:query", { + sql: `SELECT r.direction + FROM freight.routes r + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT' + WHERE r.deleted_at IS NULL`, + }).then(({ rows }) => { + expect(rows, "export route").to.have.length.at.least(1); + expect(rows[0].direction).to.eq("EXPORT"); + }); + }); + + it("operations schedules the export train from the built consist", () => { + cy.loginBackoffice(opsStaff); + + // One departure per route per day — a warm DB from a previous run already + // has this train scheduled, so only create when none is live. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n + FROM freight.train_schedules ts + JOIN freight.routes r ON r.id = ts.route_id + JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO' + WHERE ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL`, + }).then(({ rows }) => { + if (Number(rows[0].n) > 0) return; + + cy.visit("/dashboard/operations/train-scheduling-v2"); + cy.contains("button", "New schedule", { timeout: 20000 }).click(); + cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible"); + + cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD)); + + // Two days out, local datetime-local format. + const departure = new Date(Date.now() + 2 * 86400000); + const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + cy.get('.mantine-Modal-content input[type="datetime-local"]') + .clear({ force: true }) + .type(local, { force: true }); + + cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE)); + cy.get(".mantine-Modal-content").contains("button", "Create").click(); + + // Create navigates straight to the new schedule's detail page. + cy.location("pathname", { timeout: 30000 }).should( + "match", + /\/dashboard\/operations\/train-scheduling-v2\/.+/, + ); + }); + + dbSchedule().then(({ rows }) => { + expect(rows, "created schedule").to.have.length(1); + expect(rows[0].direction).to.eq("EXPORT"); + }); + }); + + // ── Intercity ride-along: accept → pay → allocated ──────────────────────── + + it("operations accepts the intercity booking onto the export train", () => { + cy.loginBackoffice(opsStaff); + dbSchedule().then(({ rows: schedules }) => { + cy.visit(`/dashboard/operations/train-scheduling-v2/${schedules[0].id}`); + }); + + cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click(); + // Presence, not viewport visibility — the panel can sit below the fold / + // inside clipped layout once earlier runs' rows stack up. + cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist"); + + withBooking((b) => { + cy.contains("tr", b.reference, { timeout: 30000 }) + .find('input[type="checkbox"]') + .check({ force: true }); + cy.contains("button", /Accept .*onto this train/).click(); + + // Accepted table shows the pay-window state (inside a horizontal + // Table.ScrollContainer — assert presence, not viewport visibility). + cy.contains("Awaiting payment", { timeout: 30000 }).should("exist"); + }); + + // Export parity: the ride-along's pay deadline never outlives the export + // booking window (reserve() clamps it to window_closes_at). + dbSchedule().then(({ rows: schedules }) => { + withBooking((b) => { + expect(b.status).to.eq("SELECTED_FOR_BATCH"); + expect(b.train_schedule_id).to.eq(schedules[0].id); + expect(b.payment_deadline, "pay deadline set").to.be.a("string"); + expect(new Date(b.payment_deadline!).getTime()).to.be.at.most( + new Date(schedules[0].window_closes_at).getTime(), + ); + }); + }); + }); + + it("staff mark the ride-along paid — booking allocates onto the train", () => { + // ScheduleBatchPanel (the only "Mark paid" button) is not mounted in the + // current UI, so drive the staff override endpoint directly. + withBooking((b) => { + cy.apiLogin(opsStaff).then(({ token }) => { + cy.request({ + method: "POST", + url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + }); + + withBooking((b) => { + expect(b.status).to.eq("PAID"); + expect(b.scheduling_status).to.eq("SCHEDULED"); + expect(b.train_schedule_id, "still pinned to the export train").to.be.a("string"); + + // The schedule↔booking link row is what makes the booking visible on the + // train board, in yard work, and to the wagon planner. + cy.task<{ rows: Array<{ n: string }> }>("db:query", { + sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE booking_id = $1 AND deleted_at IS NULL`, + params: [b.id], + }).then(({ rows }) => { + expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1); + }); + }); + }); +}); + +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-export.sql b/e2e/freight/cypress/fixtures/seed-export.sql new file mode 100644 index 000000000..574f43f26 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-export.sql @@ -0,0 +1,85 @@ +-- Arrange-data for flows/export_one_time.cy.ts. Idempotent. +-- Run AFTER seed-intercity.sql (reuses its container types, locomotives, +-- built train TRN-E2E-1 and yard distances). +-- +-- 1. bulk cargo hierarchy: group "E2E Grains" → commodity "E2E Wheat", +-- carried on CW4 covered wagons +-- 2. two CW4 wagons coupled onto the train (bulk capacity) +-- 3. LIVE export rates for Mojo → Djibouti Port: container (per container) +-- and bulk (per ton) — booking pricing hard-blocks without them + +-- 0. Approved exporter profile — picking "Export" in the wizard opens the +-- "Set up your exporter profile" modal unless the company already has an +-- active exporter profile (seed-company.sql only creates the importer). +INSERT INTO freight.company_profiles (id, company_id, type, status, reference) +SELECT gen_random_uuid(), c.id, 'exporter', 'active', 'EXP-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 = 'exporter' + ); + +-- 1a. Cargo type group + commodity. +INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active) +SELECT gen_random_uuid(), 'E2E_GRAINS', 'E2E Grains', true +WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_GRAINS'); + +INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active) +SELECT gen_random_uuid(), 'E2E_WHEAT', 'E2E Wheat', g.id, true +FROM freight.cargo_types g +WHERE g.code = 'E2E_GRAINS' + AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_WHEAT'); + +-- 1b. Wheat rides CW4 covered wagons. +INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.cargo_types ct +JOIN freight.wagon_types wt ON wt.code = 'CW4' +WHERE ct.code IN ('E2E_WHEAT', 'E2E_GRAINS') + AND NOT EXISTS ( + SELECT 1 FROM freight.cargo_type_wagon_types x + WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id + ); + +-- 2. Couple two free CW4 wagons onto the train, parked at Mojo with it. +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = 100 + sub.rn, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO') +FROM freight.trains t, + LATERAL ( + SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn + FROM freight.wagons w2 + JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4' + WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL + ORDER BY w2.wagon_number + LIMIT 2 + ) sub +WHERE t.code = 'TRN-E2E-1' + AND w.id = sub.id + AND NOT EXISTS ( + SELECT 1 FROM freight.wagons wx + JOIN freight.wagon_types wxt ON wxt.id = wx.wagon_type_id AND wxt.code = 'CW4' + WHERE wx.train_id = t.id + ); + +-- 3. LIVE export rates Mojo → Djibouti Port. +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value, + v.unit, 'LIVE', a.id, b.id, u.id +FROM (VALUES + ('CONTAINER_EXPORT', 'CONTAINER', 600, 'PER_CONTAINER'), + ('BULK_EXPORT', 'BULK', 25, 'PER_TON') + ) AS v(rate_type, applies_to, value, unit) +JOIN freight.yards a ON a.code = 'MOJO' +JOIN freight.yards b ON b.code = 'DJIB_PORT' +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = v.rate_type + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL +); diff --git a/e2e/freight/cypress/fixtures/seed-intercity.sql b/e2e/freight/cypress/fixtures/seed-intercity.sql new file mode 100644 index 000000000..732624ff4 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-intercity.sql @@ -0,0 +1,131 @@ +-- Arrange-data for flows/intercity_one_time.cy.ts. Idempotent. +-- +-- The e2e DB boots with yards + a wagon fleet only: no container types, no +-- locomotives, no Train-Builder train, no yard distances, no routes. The spec +-- drives route + schedule creation through the UI; this fixture provides only +-- the infrastructure the UI journey cannot reasonably create in-flow: +-- +-- 1. container types (booking form resolves 20ft/40ft by size_ft) +-- 2. container-type → wagon-type allow-list (wagon planner) +-- 3. two locomotives (a schedulable train needs >= 2) +-- 4. a built Train-Builder train at Mojo with four NW5 flat wagons +-- 5. yard distances for Mojo–Dire Dawa–Djibouti Port (route creation +-- refuses unconfigured pairs) + +-- 1. Container types. +INSERT INTO freight.container_types (id, code, label, size_ft, is_active) +SELECT gen_random_uuid(), v.code, v.label, v.size_ft, true +FROM (VALUES ('20FT', '20FT', 20), ('40FT', '40FT', 40)) AS v(code, label, size_ft) +WHERE NOT EXISTS (SELECT 1 FROM freight.container_types t WHERE t.code = v.code); + +-- 2. 20ft/40ft containers ride NW5 flat wagons. +INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.container_types ct +JOIN freight.wagon_types wt ON wt.code = 'NW5' +WHERE ct.code IN ('20FT', '40FT') + AND NOT EXISTS ( + SELECT 1 FROM freight.container_type_wagon_types x + WHERE x.container_type_id = ct.id AND x.wagon_type_id = wt.id + ); + +-- 3. Two locomotives at Mojo (status defaults to AVAILABLE). +INSERT INTO freight.locomotives + (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id) +SELECT gen_random_uuid(), v.code, 4000, 760, y.id +FROM (VALUES ('LOCO-E2E-1'), ('LOCO-E2E-2')) AS v(code) +JOIN freight.yards y ON y.code = 'MOJO' +WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); + +-- 4a. Built train at Mojo (status defaults to AVAILABLE). +INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id) +SELECT gen_random_uuid(), 'TRN-E2E-1', 'E2E Export Carrier', 2000, y.id +FROM freight.yards y +WHERE y.code = 'MOJO' + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-E2E-1'); + +-- 4b. Couple both locomotives (available-trains filter requires >= 2). +INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no) +SELECT gen_random_uuid(), t.id, l.id, + row_number() OVER (ORDER BY l.code) - 1 +FROM freight.trains t +JOIN freight.locomotives l ON l.code IN ('LOCO-E2E-1', 'LOCO-E2E-2') +WHERE t.code = 'TRN-E2E-1' + AND NOT EXISTS ( + SELECT 1 FROM freight.train_locomotives tl + WHERE tl.train_id = t.id AND tl.locomotive_id = l.id + ); + +-- 4c. Couple four free NW5 flat wagons onto the train and park them at Mojo +-- with it (the seeded fleet sits at Doraleh; the planner reads the consist by +-- train_id, the yard only matters for warnings). +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = sub.rn, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO') +FROM freight.trains t, + LATERAL ( + SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn + FROM freight.wagons w2 + JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5' + WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL + ORDER BY w2.wagon_number + LIMIT 4 + ) sub +WHERE t.code = 'TRN-E2E-1' + AND w.id = sub.id + AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id); + +-- 4d. One REQUIRED intercity clearance document, so the journey exercises the +-- real customer-upload → ops-review → finalize step (the seeder leaves the +-- intercity_documents setting empty). +INSERT INTO freight.file_upload_fields + (id, setting_id, file_key, file_label, is_required, is_multiple, max_files, + allowed_extensions, max_size_mb, display_order) +SELECT gen_random_uuid(), s.id, 'cargo_manifest', 'Cargo Manifest', true, false, 1, + '{pdf,jpg,jpeg,png}'::text[], 10, 1 +FROM freight.file_upload_settings s +WHERE s.code = 'intercity_documents' + AND NOT EXISTS ( + SELECT 1 FROM freight.file_upload_fields f + WHERE f.setting_id = s.id AND f.file_key = 'cargo_manifest' AND f.deleted_at IS NULL + ); + +-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing +-- hard-blocks any container line without a rate on its exact leg; rates are +-- configured in USD and converted to the booking currency). +INSERT INTO freight.rates + (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, + origin_yard_id, destination_yard_id, proposed_by_staff_id) +SELECT gen_random_uuid(), 'INTERCITY_CONTAINER', 'INTERCITY', 'ALWAYS', 'USD', 500, + 'PER_CONTAINER', 'LIVE', a.id, b.id, u.id +FROM freight.yards a +JOIN freight.yards b ON b.code = 'DIRE_DAWA' +JOIN iam.users u ON u.email = 'operation@edr.local' +WHERE a.code = 'MOJO' + AND NOT EXISTS ( + SELECT 1 FROM freight.rates r + WHERE r.rate_type = 'INTERCITY_CONTAINER' + AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id + AND r.deleted_at IS NULL + ); + +-- 6. Segment distances (symmetric — one row covers both directions). Guarded: +-- an e2e image built from a branch that predates the yard_distances feature +-- has no table, and its route form doesn't require distances either. +DO $$ +BEGIN + IF to_regclass('freight.yard_distances') IS NOT NULL THEN + INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km) + SELECT gen_random_uuid(), a.id, b.id, v.km + FROM (VALUES ('MOJO', 'DIRE_DAWA', 300), ('DIRE_DAWA', 'DJIB_PORT', 450)) + AS v(from_code, to_code, km) + JOIN freight.yards a ON a.code = v.from_code + JOIN freight.yards b ON b.code = v.to_code + WHERE NOT EXISTS ( + SELECT 1 FROM freight.yard_distances d + WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id) + OR (d.from_yard_id = b.id AND d.to_yard_id = a.id) + ); + END IF; +END $$; 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..2d18350a0 --- /dev/null +++ b/e2e/freight/cypress/support/commands.ts @@ -0,0 +1,179 @@ +/** + * 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
-
-
- - - - -