diff --git a/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts new file mode 100644 index 000000000..499944a05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds `reference` to freight.audit_logs — the human identifier of the entity + * the action touched (booking reference, schedule number, train number, …), + * resolved at write time by the audit interceptor. `resource_id` stays the + * machine id; this column is what staff actually type into the search box. + * + * Production safety: + * - `ADD COLUMN ... NOT NULL DEFAULT ''` is metadata-only on Postgres 11+: + * no table rewrite, no long lock, existing rows read '' without being + * touched. Rows written before this migration keep '' permanently — + * capture starts from deploy, by design (no backfill). + * - Everything is IF NOT EXISTS so a hand-patched database converges + * instead of failing the deploy. + * - No existing column is altered and nothing is dropped: zero data-loss + * surface. + * + * The index is an expression index on upper(reference) with + * text_pattern_ops so the search endpoint's case-insensitive prefix match + * (`upper(reference) LIKE upper($1) || '%'`) is indexed. '' rows are + * excluded to keep it small — they are never searched for. + */ +export class AuditLogReference3690000000000 implements MigrationInterface { + name = 'AuditLogReference3690000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.audit_logs + ADD COLUMN IF NOT EXISTS reference varchar(64) NOT NULL DEFAULT '' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_audit_logs_reference_upper + ON freight.audit_logs (upper(reference) text_pattern_ops) + WHERE reference <> '' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Down discards every captured reference — acceptable only because down + // migrations are never run against production here. + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_audit_logs_reference_upper`); + await queryRunner.query(`ALTER TABLE freight.audit_logs DROP COLUMN IF EXISTS reference`); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index 019d99920..50093ea67 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -12,7 +12,7 @@ * humanized handler name where a route has none. * * Excludes the AI Assist and Account entities. - * Generated from the controllers under src/ — 517 endpoints. + * Generated from the controllers under src/ — 528 endpoints. */ /** [title, method, entity] for one auditable route. */ export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; @@ -38,6 +38,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/skip": ["GL ET skips the draft-declaration round: no estimate is sent to the customer, the real declaration is filed directly and duty & tax passes by default", "POST", "Booking"], "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], @@ -51,7 +52,12 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/clearance/charges/port-document": ["GL Djibouti uploads the port-charges document", "POST", "Booking"], "PATCH /api/bookings/:id/clearance/charges/:chargeId/bill": ["GL Ethiopia sets or revises a clearance charge's amount + currency", "PATCH", "Booking"], "POST /api/bookings/:id/clearance/charges/:chargeId/send": ["GL Ethiopia issues the clearance charge invoice to the customer", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/:chargeId/accept": ["Customer accepts a proposed clearance charge — issues the payable invoice and locks the charge", "POST", "Booking"], + "POST /api/bookings/:id/clearance/charges/:chargeId/reject": ["Customer rejects a proposed clearance charge with a reason — GL Ethiopia revises and re-sends", "POST", "Booking"], "POST /api/bookings/:id/clearance/charges/miscellaneous": ["GL Ethiopia creates the miscellaneous clearance charge", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges": ["Finance raises a new additional charge — draft, or send to the customer immediately", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges/:chargeId/send": ["Issue the draft charge's payable invoice and notify the customer", "POST", "Booking"], + "POST /api/bookings/:id/additional-charges/:chargeId/cancel": ["Withdraw a draft or unpaid additional charge", "POST", "Booking"], "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], @@ -73,7 +79,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], - "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + // "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], @@ -85,7 +91,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], - "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + // "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], "POST /api/bookings/consolidation-approvals/:approvalId/approve": ["Approve a shared wagon: both bookings leave the gate and continue to Operations together.", "POST", "Booking"], @@ -207,7 +213,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], - "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + // "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], @@ -240,7 +246,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], - "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + // "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], // Driver "POST /api/drivers": ["Create a new driver", "POST", "Driver"], @@ -266,6 +272,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"], + "POST /api/invoices/eims/bulk-register": ["Submit multiple invoices to MoR EIMS in one call. Asynchronous — this only confirms MoR", "POST", "EIMS Invoice"], + "POST /api/eims/webhook/bulk-register": ["EIMS bulk-register webhook callback (MoR reports per-invoice results)", "POST", "EIMS Invoice"], // Exchange Setting "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], @@ -373,6 +381,14 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + // Operations Standard + "PATCH /api/operations-standards": ["Change one or more operating standards", "PATCH", "Operations Standard"], + + // Operations Target + "POST /api/operations-targets": ["Create a planned target", "POST", "Operations Target"], + "PATCH /api/operations-targets/:id": ["Update a planned target", "PATCH", "Operations Target"], + "DELETE /api/operations-targets/:id": ["Soft-delete a planned target", "DELETE", "Operations Target"], + // Organization User "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], @@ -445,7 +461,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { // Two controllers register this same path; Nest serves whichever module loads first. "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], - "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + // "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], // Service Type "POST /api/service-types": ["Create a service type", "POST", "Service Type"], @@ -463,7 +480,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { // Shipping Line Booking "POST /api/shipping-line-bookings/initiate": ["Initiate a bare booking (no contract). Starts at AWAITING_DOCUMENTS so the shipping line can upload its documents for Operations to approve.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/cancel": ["Cancel one of the signed-in shipping line's own bookings. Allowed only before the booking is priced.", "POST", "Shipping Line Booking"], - "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], + // "POST /api/shipping-line-bookings/:id/price-preview": ["Authoritative price quote for the completion payload — same compute as /complete, saved as the booking's breakdown + rate snapshots (refreshed on every re-preview). Persists nothing else.", "POST", "Shipping Line Booking"], "POST /api/shipping-line-bookings/:id/complete": ["Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day.", "POST", "Shipping Line Booking"], // Shipping Line Credit @@ -510,6 +527,8 @@ export const AUDIT_ENDPOINTS: Readonly> = { "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "PATCH /api/train-builder/:id/wagons/:wagonId/yard": ["Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated", "PATCH", "Train Build"], + "PATCH /api/train-builder/:id/wagons/yard": ["Move several coupled wagons to another yard in one transaction — refused outright if any is allocated to a live schedule", "PATCH", "Train Build"], "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], @@ -520,16 +539,16 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], - "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], - "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], - "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + // "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], @@ -564,6 +583,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/wagon-yards": ["Re-plan the yard this departure boards wagons from and/or cuts them at (schedule-only; physical yards untouched, dispatch requires alignment)", "PATCH", "Train Schedule"], "POST /api/train-scheduling/schedules/:id/merge": ["Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", "POST", "Train Schedule"], "PATCH /api/train-scheduling/schedules/:id/checkpoints/:sequenceNo": ["Edit a logged leg", "PATCH", "Train Schedule"], @@ -611,7 +631,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], - "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + // "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], diff --git a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts index 91d6c9901..dcbe53306 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts @@ -1,10 +1,11 @@ import { Injectable } from '@nestjs/common'; import { BaseRepository } from '@edr/api-common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { AuditLog } from './entities/audit-log.entity'; +import type { AuditReferenceSource } from './audit-reference.registry'; export interface AuditLogQuery { type?: string; @@ -12,6 +13,10 @@ export interface AuditLogQuery { method?: string; isSuccess?: boolean; resourceId?: string; + reference?: string; + userName?: string; + title?: string; + q?: string; from?: Date; to?: Date; skip: number; @@ -40,30 +45,91 @@ export class AuditLogRepository extends BaseRepository { ); } + /** + * Resolve the human identifier for one entity row (`WHERE id = $1`). + * + * `source` comes from the static `AUDIT_REFERENCE_SOURCES` registry — never + * from user input — so interpolating its table/column is safe; the id is + * bound as a parameter. Returns null when the row doesn't exist or the + * identifier column is empty. + */ + async lookupReference( + source: AuditReferenceSource, + id: string, + ): Promise { + const rows = await this.auditLogRepository.manager.query< + { reference: string | null }[] + >( + `SELECT ${source.column}::varchar AS reference FROM ${source.table} WHERE id = $1::uuid`, + [id], + ); + return rows[0]?.reference || null; + } + /** * Paginated, filtered read. Newest first — every index on this table is * ordered `created_at DESC` to match. + * + * Query builder rather than `findAndCount`: `q` needs an OR across four + * columns, and `reference` needs the `upper(...) LIKE` shape that matches + * the expression index — neither fits `FindOptionsWhere`. */ async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { - const where: FindOptionsWhere = {}; + const qb = this.auditLogRepository.createQueryBuilder('audit_log'); - if (query.type) where.type = query.type; - if (query.userId) where.userId = query.userId; - if (query.method) where.method = query.method; - if (query.resourceId) where.resourceId = query.resourceId; - if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess; + if (query.type) qb.andWhere('audit_log.type = :type', { type: query.type }); + if (query.userId) qb.andWhere('audit_log.user_id = :userId', { userId: query.userId }); + if (query.method) qb.andWhere('audit_log.method = :method', { method: query.method }); + if (query.resourceId) { + qb.andWhere('audit_log.resource_id = :resourceId', { resourceId: query.resourceId }); + } + if (query.isSuccess !== undefined) { + qb.andWhere('audit_log.is_success = :isSuccess', { isSuccess: query.isSuccess }); + } + + // Case-insensitive prefix match, shaped to hit idx_audit_logs_reference_upper. + // The explicit <> '' repeats the index's partial predicate — without it the + // planner cannot prove the partial index applies and falls back to a scan. + if (query.reference) { + qb.andWhere("audit_log.reference <> ''").andWhere( + "upper(audit_log.reference) LIKE upper(:reference) || '%'", + { reference: escapeLike(query.reference) }, + ); + } + if (query.userName) { + qb.andWhere('audit_log.user_name ILIKE :userName', { + userName: `%${escapeLike(query.userName)}%`, + }); + } + if (query.title) { + qb.andWhere('audit_log.title ILIKE :title', { + title: `%${escapeLike(query.title)}%`, + }); + } + + // One search box across the columns staff actually search by. + // ponytail: ILIKE %…% scans the time-bounded window; add pg_trgm GIN + // indexes if the table grows past a few million rows. + if (query.q) { + const q = `%${escapeLike(query.q)}%`; + qb.andWhere( + `(audit_log.reference ILIKE :q + OR audit_log.resource_id ILIKE :q + OR audit_log.user_name ILIKE :q + OR audit_log.title ILIKE :q)`, + { q }, + ); + } // Date range: either bound may be supplied alone. - if (query.from && query.to) where.createdAt = Between(query.from, query.to); - else if (query.from) where.createdAt = MoreThanOrEqual(query.from); - else if (query.to) where.createdAt = LessThanOrEqual(query.to); + if (query.from) qb.andWhere('audit_log.created_at >= :from', { from: query.from }); + if (query.to) qb.andWhere('audit_log.created_at <= :to', { to: query.to }); - return this.auditLogRepository.findAndCount({ - where, - order: { createdAt: 'DESC' }, - skip: query.skip, - take: query.take, - }); + return qb + .orderBy('audit_log.created_at', 'DESC') + .skip(query.skip) + .take(query.take) + .getManyAndCount(); } /** Distinct entity types present, for populating a filter dropdown. */ @@ -76,4 +142,20 @@ export class AuditLogRepository extends BaseRepository { return rows.map((row) => row.type); } + + /** Distinct action titles present, for the action filter dropdown. */ + async distinctTitles(): Promise { + const rows = await this.auditLogRepository + .createQueryBuilder('audit_log') + .select('DISTINCT audit_log.title', 'title') + .orderBy('audit_log.title', 'ASC') + .getRawMany<{ title: string }>(); + + return rows.map((row) => row.title); + } +} + +/** Escape LIKE wildcards so a literal `%`/`_` in the search text stays literal. */ +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, (ch) => `\\${ch}`); } diff --git a/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts new file mode 100644 index 000000000..9cba31abb --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts @@ -0,0 +1,39 @@ +/** + * Where each audited entity type keeps its human identifier — the value staff + * search by (booking reference, train number, invoice number). + * + * Used by `AuditService.record` for a single indexed primary-key lookup at + * write time. Types not listed simply get `reference = ''`; the lookup is + * best-effort and an audit row is never lost over it. + * + * Table and column names are static values from this file — never user input — + * so interpolating them into SQL is safe. Ids are always bound as parameters. + */ +export interface AuditReferenceSource { + /** Schema-qualified table holding the entity. */ + readonly table: string; + /** Column with the human identifier. */ + readonly column: string; +} + +export const AUDIT_REFERENCE_SOURCES: Readonly> = { + Booking: { table: 'freight.bookings', column: 'reference' }, + Contract: { table: 'freight.contracts', column: 'reference' }, + // "Schedule" (reschedule module) and "Train Schedule" are the same table. + Schedule: { table: 'freight.train_schedules', column: 'reference' }, + 'Train Schedule': { table: 'freight.train_schedules', column: 'reference' }, + Train: { table: 'freight.trains', column: 'train_number' }, + // Train Build routes carry the train id in :id. + 'Train Build': { table: 'freight.trains', column: 'train_number' }, + Wagon: { table: 'freight.wagons', column: 'wagon_number' }, + Locomotive: { table: 'freight.locomotives', column: 'code' }, + 'EIMS Invoice': { table: 'freight.invoices', column: 'invoice_number' }, + // Payment paths mostly carry an invoice id; the ones that don't (e.g. + // redirect-success/:bookingId) miss the lookup and fall back to ''. + Payment: { table: 'freight.invoices', column: 'invoice_number' }, + Vehicle: { table: 'freight.vehicles', column: 'plate_number' }, + Company: { table: 'freight.companies', column: 'name' }, +}; + +/** Lookups run `WHERE id = $1::uuid` — guard non-uuid ids (template codes…). */ +export const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts index 1a07a5fe5..2e08b2cd9 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.controller.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -43,4 +43,13 @@ export class AuditController { types(): Promise { return this.auditService.listTypes(); } + + @Get('actions') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: 'Distinct action titles present in the audit log (filter dropdown)', + }) + actions(): Promise { + return this.auditService.listActions(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts index de7dfd956..9816d79f5 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -4,6 +4,10 @@ import { PaginatedResponse } from '@edr/types'; import { AuditLog } from './entities/audit-log.entity'; import { AuditLogRepository } from './audit-log.repository'; import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { + AUDIT_REFERENCE_SOURCES, + UUID_PATTERN, +} from './audit-reference.registry'; import { buildPaginationMeta, normalizePagination, @@ -25,6 +29,7 @@ export class AuditService { */ async record(entry: Partial): Promise { try { + entry.reference = await this.resolveReference(entry.type, entry.resourceId); await this.auditLogRepository.record(entry); } catch (error) { this.logger.error( @@ -35,6 +40,34 @@ export class AuditService { } } + /** + * Best-effort human identifier (booking reference, train number, …) for the + * entity the action touched — one primary-key lookup against the table + * registered for the type. Always returns a string: '' when the type has no + * registered source, the id isn't a uuid (template codes), the row is gone, + * or the lookup itself fails. A missing reference must never cost the audit + * row, so failures degrade to '' rather than throwing. + */ + private async resolveReference( + type: string | undefined, + resourceId: string | null | undefined, + ): Promise { + const source = type ? AUDIT_REFERENCE_SOURCES[type] : undefined; + if (!source || !resourceId || !UUID_PATTERN.test(resourceId)) return ''; + + try { + const reference = await this.auditLogRepository.lookupReference(source, resourceId); + return reference?.slice(0, 64) ?? ''; + } catch (error) { + this.logger.warn( + `Reference lookup failed for ${type} ${resourceId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return ''; + } + } + /** Paginated, filtered audit history, newest first. */ async search(query: AuditLogQueryDto): Promise> { const { page, pageSize, skip, take } = normalizePagination(query); @@ -53,6 +86,10 @@ export class AuditService { userId: query.userId, method: query.method, resourceId: query.resourceId, + reference: query.reference, + userName: query.userName, + title: query.title, + q: query.q, isSuccess: query.isSuccess === undefined ? undefined : query.isSuccess === 'true', from, @@ -68,4 +105,9 @@ export class AuditService { async listTypes(): Promise { return this.auditLogRepository.distinctTypes(); } + + /** Distinct action titles, for the action filter dropdown. */ + async listActions(): Promise { + return this.auditLogRepository.distinctTitles(); + } } diff --git a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts index 5a5199538..dbd41e14c 100644 --- a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts +++ b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts @@ -39,6 +39,44 @@ export class AuditLogQueryDto extends PaginationQueryDto { @MaxLength(64) resourceId?: string; + @ApiPropertyOptional({ + description: + 'Human identifier of the affected record — booking reference, schedule number, train number. Case-insensitive prefix match.', + example: 'S-2026-00045', + }) + @IsOptional() + @IsString() + @MaxLength(64) + reference?: string; + + @ApiPropertyOptional({ + description: 'Staff name, case-insensitive substring match.', + example: 'Mulu', + }) + @IsOptional() + @IsString() + @MaxLength(150) + userName?: string; + + @ApiPropertyOptional({ + description: 'Action title, case-insensitive substring match.', + example: 'Cancel booking', + }) + @IsOptional() + @IsString() + @MaxLength(255) + title?: string; + + @ApiPropertyOptional({ + description: + 'Free-text search across reference, resource id, staff name and action title.', + example: 'B-2026-00120', + }) + @IsOptional() + @IsString() + @MaxLength(100) + q?: string; + @ApiPropertyOptional({ description: 'Filter by outcome: true = succeeded, false = failed.', }) diff --git a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts index 0ff7727ff..8261fc61c 100644 --- a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts +++ b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts @@ -86,6 +86,20 @@ export class AuditLog { @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) resourceId?: string | null; + /** + * Human identifier of the affected record — booking reference, schedule + * number, train number — resolved at write time from + * `AUDIT_REFERENCE_SOURCES`. This is what staff type into the search box; + * `resourceId` stays the machine id. + * + * `''` (never NULL) when the entity type has no registered source, the + * lookup found nothing, or the row predates the column. Empty string keeps + * search SQL to one shape and matches how pre-existing rows read after the + * metadata-only migration. + */ + @Column({ name: 'reference', type: 'varchar', length: 64, default: '' }) + reference!: string; + /** * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads * are reduced to `{ __file, originalName, mimeType, size }` descriptors — diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts index 8babc8c2e..07d4c4e06 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { expect(cut.weightTons).toBeCloseTo(62.625, 3); }); }); + +/** + * Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL + * must pick the consolidation partner — no partner, no rebook; a partner + * already paired elsewhere is refused. + */ +describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => { + const units = Array.from({ length: 3 }, (_, i) => ({ + containerSize: '20ft', + containerNumber: `CONT${i}`, + sealNumber: null, + vgmTons: 10, + isHazardous: false, + isReefer: false, + })); + const row = { + id: 'wc1', + bookingId: 'b1', + status: 'CREDIT_AVAILABLE', + creditAmount: 100, + cancelledQuantities: { bySize: { '20ft': 3 }, units }, + }; + const source = { + id: 'b1', + contractId: 'c1', + paymentCurrency: 'USD', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + }; + + const makeSvc = (partner?: unknown) => { + const svc = Object.create(BookingWagonCancellationService.prototype) as Record< + string, + unknown + > & { + rebook(id: string, dto: unknown): Promise; + }; + svc.repo = { findById: async () => row }; + svc.bookingsRepository = { + findById: async () => source, + findByIdWithFiles: async () => partner ?? null, + }; + return svc; + }; + + it('refuses an odd-20ft rebook without a GL-picked partner', async () => { + await expect( + makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.toThrow(/pick a consolidation partner/i); + }); + + it('refuses a partner that already shares a wagon', async () => { + const paired = { + id: 'p1', + reference: 'BK-1', + status: 'SUBMITTED', + consolidationPartnerId: 'someone-else', + }; + await expect( + makeSvc(paired).rebook('wc1', { + scheduledDate: '2026-09-01', + partnerBookingId: 'p1', + }), + ).rejects.toThrow(/already shares a wagon/i); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index d3e3edfe5..17d1ba2dc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,7 +7,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -126,6 +126,7 @@ export class BookingWagonCancellationService { @Inject(forwardRef(() => FirstMileService)) private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, + private readonly events: EventEmitter2, ) {} // ── T1: request ──────────────────────────────────────────────────────────── @@ -782,6 +783,26 @@ export class BookingWagonCancellationService { const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers); // Same currency as the source booking — the credit is in it. createDto.paymentCurrency = source.paymentCurrency ?? undefined; + + // An odd-20ft credit shares a wagon again on rebook. GL picks who — never + // the auto-matcher (it could claim a partner behind GL's back), so the + // create below runs with auto-consolidation off and the chosen partner is + // linked once the booking exists and is PAID. + const oddFt20 = this.creditFt20(row) % 2 === 1; + let partner: Booking | null = null; + if (oddFt20) { + createDto.skipAutoConsolidation = true; + if (!dto.partnerBookingId) { + throw new BadRequestException( + 'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).', + ); + } + partner = await this.loadRebookPartner( + source, + dto.partnerBookingId, + dto.scheduledDate, + ); + } const created = await this.contractBooking.createUnderContract( source.contractId, createDto, @@ -814,12 +835,19 @@ export class BookingWagonCancellationService { `First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, ); } - try { - await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); - } catch (err) { - this.logger.error( - `Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, - ); + if (partner) { + // Consolidated rebook: never allocate the half-wagon booking alone. It + // rides PAID and the batch engine settles the pair atomically once the + // partner's own invoice is paid. + await this.pairRebookedBooking(newBookingId, partner); + } else { + try { + await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); + } catch (err) { + this.logger.error( + `Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } } const updated = (await this.repo.update(row.id, { @@ -837,6 +865,142 @@ export class BookingWagonCancellationService { return { cancellation: updated, bookingId: newBookingId }; } + /** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */ + private creditFt20(row: BookingWagonCancellation): number { + return Object.entries(row.cancelledQuantities?.bySize ?? {}) + .filter(([size]) => sizeFtOf(size) === 20) + .reduce((sum, [, qty]) => sum + Number(qty || 0), 0); + } + + /** + * Partner candidates for rebooking an odd-20ft credit — what the GL rebook + * form lists. Empty when the credit is even (no shared wagon) or spent. + */ + async rebookPartnerCandidates( + cancellationId: string, + scheduledDate: string, + ): Promise< + Array<{ + id: string; + reference: string; + companyName: string | null; + status: string; + scheduledDate: string | null; + ft20Quantity: number; + }> + > { + const row = await this.mustFind(cancellationId); + if (row.status !== 'CREDIT_AVAILABLE') return []; + if (this.creditFt20(row) % 2 === 0) return []; + const source = await this.bookingsRepository.findById(row.bookingId); + if (!source) return []; + const rows = await this.bookingsRepository.findRebookConsolidationCandidates( + source, + new Date(scheduledDate), + ); + return rows.map((b) => ({ + id: b.id, + reference: b.reference, + companyName: b.company?.name ?? null, + status: b.status, + scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null, + ft20Quantity: (b.bookingContainers ?? []) + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + })); + } + + /** The GL-picked partner, validated to actually fit the rebooked shared wagon. */ + private async loadRebookPartner( + source: Booking, + partnerId: string, + scheduledDate: string, + ): Promise { + const partner = await this.bookingsRepository.findByIdWithFiles(partnerId); + if (!partner) { + throw new NotFoundException(`Partner booking ${partnerId} not found.`); + } + if (partner.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} already shares a wagon with another booking.`, + ); + } + if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) { + throw new BadRequestException( + `Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`, + ); + } + if ( + partner.originYardId !== source.originYardId || + partner.destinationYardId !== source.destinationYardId || + partner.tradeDirection !== source.tradeDirection + ) { + throw new BadRequestException( + `Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`, + ); + } + const eatDay = (d: Date | string) => + new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); + if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) { + throw new BadRequestException( + `Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`, + ); + } + const ft20 = (partner.bookingContainers ?? []) + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + if (ft20 % 2 !== 1) { + throw new BadRequestException( + `Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`, + ); + } + return partner; + } + + /** + * Link the rebooked (already PAID) booking with the GL-picked partner. A + * parked partner is resumed the way pairConsolidation would resume it — + * but only the partner: the rebooked side's PAID status must survive, so + * the link is written directly. The paired event then runs the partner's + * deferred contract finalize (invoice → pay window); the shared wagon + * boards once that invoice is paid. + */ + private async pairRebookedBooking( + newBookingId: string, + partner: Booking, + ): Promise { + // ponytail: validate-then-link without a row lock — a concurrent claim in + // this window loses silently; move to pairConsolidationIfUnpaired-style + // locking if it ever bites. + const fresh = await this.dataSource.getRepository(Booking).findOne({ + where: { id: partner.id }, + select: { id: true, consolidationPartnerId: true, status: true }, + }); + if (!fresh || fresh.consolidationPartnerId) { + throw new ConflictException( + `Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`, + ); + } + if (fresh.status === 'PENDING_CONSOLIDATION') { + await this.dataSource.getRepository(Booking).update(partner.id, { + status: partner.consolidationResumeStatus ?? 'SUBMITTED', + consolidationResumeStatus: null, + }); + } + await this.bookingsRepository.linkConsolidationPartners( + newBookingId, + partner.id, + ); + this.events.emit('booking.consolidation.paired', { + bookingIds: [partner.id], + }); + this.notifyCustomer( + partner, + 'Consolidation partner found', + `${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`, + ); + } + // ── History ──────────────────────────────────────────────────────────────── list(filter: WagonCancellationListFilter) { 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 04ebc2d41..c76655240 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -725,6 +725,30 @@ export class BookingsController { return this.wagonCancellationService.withdraw(cancellationId); } + @Get("wagon-cancellations/:cancellationId/rebook-partners") + @ApiOperation({ + summary: + "Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)", + }) + async listRebookPartners( + @Param("cancellationId", ParseUUIDPipe) cancellationId: string, + @Query("scheduledDate") scheduledDate: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationRebook, + ); + if (!scheduledDate) { + throw new BadRequestException("scheduledDate is required."); + } + return this.wagonCancellationService.rebookPartnerCandidates( + cancellationId, + scheduledDate, + ); + } + @Post("wagon-cancellations/:cancellationId/rebook") @ApiOperation({ summary: diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index ff0888bef..885486031 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -377,6 +377,58 @@ export class BookingsRepository extends BaseRepository { }); } + /** + * Candidate partners for rebooking an odd-20ft cancellation credit: unpaired + * odd-20ft bookings on the same route/direction riding the requested day — + * SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION. + * Unlike {@link findManualConsolidationCandidates} this is not customs-only: + * GL picks who shares the rebooked wagon whatever the contract kind. + */ + async findRebookConsolidationCandidates( + booking: Booking, + scheduledDate: Date, + limit = 50, + ): Promise { + const rows = await this.repository + .createQueryBuilder('b') + .leftJoinAndSelect('b.bookingContainers', 'bc') + .leftJoinAndSelect('bc.containerType', 'ct') + .leftJoinAndSelect('b.company', 'company') + .where('b.id != :bookingId', { bookingId: booking.id }) + .andWhere('b.consolidationPartnerId IS NULL') + .andWhere('b.originYardId = :originYardId', { + originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { + destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { + tradeDirection: booking.tradeDirection, + }) + .andWhere('b.status IN (:...statuses)', { + statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], + }) + // Same EAT booking day as the rebook — the pair shares one physical + // wagon, so it must board one train. + .andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: scheduledDate }, + ) + .orderBy('b.createdAt', 'ASC') + .take(limit) + .getMany(); + + // Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons). + return rows.filter((row) => { + const lines = row.bookingContainers ?? []; + if (lines.length === 0) return false; + const ft20 = lines + .filter((line) => Number(line.containerType?.sizeFt) === 20) + .reduce((sum, line) => sum + Number(line.quantity || 0), 0); + return ft20 % 2 === 1; + }); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). Only 20ft lines ever diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts index 624762fac..631e78109 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto { @ValidateNested({ each: true }) @Type(() => RebookContainerLineDto) containers?: RebookContainerLineDto[]; + + @ApiPropertyOptional({ + description: + 'Required when the credit carries an odd 20ft count: the odd-20ft booking ' + + 'GL picked to share the rebooked wagon (see the rebook-partners endpoint).', + }) + @IsOptional() + @IsUUID() + partnerBookingId?: string; } export class FilterWagonCancellationsDto { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index bdb8d74cd..565164995 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -227,3 +227,85 @@ describe('ContractBookingService — quantity-cap completion', () => { }); }); }); + +/** + * The customer's shipment request is the order: GL may not change its container + * sizes/quantities or billing currency at completion — only per-unit details. + */ +describe('ContractBookingService — shipment-request lock at completion', () => { + type WithAssert = { + assertMatchesShipmentRequest( + bookingId: string, + dto: { + paymentCurrency?: string; + containers?: Array<{ containerSize: string; quantity: number }>; + bulkLines?: Array<{ cargoWeightTons?: number }>; + }, + ): Promise; + }; + + const serviceWithRequest = (request: unknown): WithAssert => { + const svc = Object.create(ContractBookingService.prototype) as WithAssert & { + dataSource: unknown; + }; + svc.dataSource = { + getRepository: () => ({ findOne: async () => request }), + }; + return svc; + }; + + const request = { + paymentCurrency: 'USD', + requestedLines: { + containers: [ + { containerSize: '20ft', quantity: 2 }, + { containerSize: '40ft', quantity: 1 }, + ], + }, + }; + + it('accepts the exact requested quantities and currency', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'USD', + containers: [ + { containerSize: '40ft', quantity: 1 }, + { containerSize: '20ft', quantity: 2 }, + ], + }), + ).resolves.toBeUndefined(); + }); + + it('rejects changed quantities', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'USD', + containers: [ + { containerSize: '20ft', quantity: 4 }, + { containerSize: '40ft', quantity: 1 }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects a changed billing currency', async () => { + await expect( + serviceWithRequest(request).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'ETB', + containers: [ + { containerSize: '20ft', quantity: 2 }, + { containerSize: '40ft', quantity: 1 }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('is a no-op without a linked request', async () => { + await expect( + serviceWithRequest(null).assertMatchesShipmentRequest('b1', { + paymentCurrency: 'ETB', + containers: [{ containerSize: '20ft', quantity: 9 }], + }), + ).resolves.toBeUndefined(); + }); +}); 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 313364d8c..051d0eabe 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 @@ -36,6 +36,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; +import { BookingRequest } from './entities/booking-request.entity'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { @@ -395,6 +396,10 @@ export class ContractBookingService { if ( withContainers && freightType === 'CONTAINER' && + // A rebooked cancellation credit carries `skipAutoConsolidation`: its + // shared-wagon partner is picked by GL in the rebook flow, so nothing may + // auto-claim (or park) it here behind GL's back. + !dto.skipAutoConsolidation && (await this.consolidationService.needsConsolidationFromBooking( withContainers, )) @@ -863,6 +868,12 @@ export class ContractBookingService { direction: contract.tradeDirection ?? null, }); + // The customer's shipment request is the order: sizes, quantities and + // billing currency are theirs — GL enters everything else. Both halves of a + // consolidated pair pass through here, so each is checked against its OWN + // request. + await this.assertMatchesShipmentRequest(booking.id, dto); + const freightType = contract.freightType; let hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || @@ -1052,6 +1063,72 @@ export class ContractBookingService { return { booking: completed, warnings }; } + /** + * The linked shipment request (customs Path B) is the customer's order: + * container sizes + quantities and the billing currency are the customer's + * choices, and GL may not change them at completion — only per-unit details + * (numbers, seals, VGM, handling) are GL's to enter. No linked request, or a + * legacy request without lines/currency ⇒ nothing to enforce. Container lines + * are checked only when the payload restates cargo (a day-only resubmit keeps + * the already-validated persisted cargo). + */ + private async assertMatchesShipmentRequest( + bookingId: string, + dto: CreateBookingUnderContractDto, + ): Promise { + const request = await this.dataSource.getRepository(BookingRequest).findOne({ + where: { createdBookingId: bookingId }, + }); + if (!request) return; + const lines = request.requestedLines ?? {}; + + if (request.paymentCurrency) { + if (dto.paymentCurrency && dto.paymentCurrency !== request.paymentCurrency) { + throw new BadRequestException( + `The customer chose ${request.paymentCurrency} on the shipment request — the billing currency cannot be changed.`, + ); + } + dto.paymentCurrency = request.paymentCurrency; + } + + if (dto.containers?.length && lines.containers?.length) { + // Compare per size in ft ("20ft" vs "20FT"/"20" spellings must not differ). + const byFt = (rows: Array<{ containerSize: string; quantity: number }>) => { + const map = new Map(); + for (const row of rows) { + const ft = parseInt(String(row.containerSize), 10); + map.set(ft, (map.get(ft) ?? 0) + Number(row.quantity || 0)); + } + return map; + }; + const requested = byFt(lines.containers); + const given = byFt(dto.containers); + const same = + requested.size === given.size && + [...requested].every(([ft, qty]) => given.get(ft) === qty); + if (!same) { + const summary = [...requested] + .map(([ft, qty]) => `${qty} × ${ft}ft`) + .join(', '); + throw new BadRequestException( + `The customer requested exactly ${summary} — container sizes and quantities cannot be changed at completion.`, + ); + } + } + + if (dto.bulkLines?.length && lines.bulk?.cargoWeightTons != null) { + const givenTons = dto.bulkLines.reduce( + (sum, l) => sum + Number(l.cargoWeightTons || 0), + 0, + ); + if (givenTons !== Number(lines.bulk.cargoWeightTons)) { + throw new BadRequestException( + `The customer requested ${lines.bulk.cargoWeightTons} tons on the shipment request — the bulk quantity cannot be changed at completion.`, + ); + } + } + } + /** * Search for a complementary partner for a parked-eligible drawdown, pair it or * park it in PENDING_CONSOLIDATION with the resume status it should return to. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index e9a747784..494522595 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -566,8 +566,9 @@ export class TrainSchedulingController { dispatchSchedule( @Param("id", ParseUUIDPipe) id: string, @Body() dto: DispatchScheduleDto, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainSchedulingService.dispatchSchedule(id, dto); + return this.trainSchedulingService.dispatchSchedule(id, dto, resolveAuthUserId(user)); } @Get("intercity/bookings") diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 5f9a7a1bc..06c363bc4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -1,11 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; import { TrainCheckpointKind } from '@edr/types'; import { + IsArray, IsEnum, IsInt, IsISO8601, IsOptional, IsString, + IsUUID, MaxLength, Min, } from 'class-validator'; @@ -113,4 +115,20 @@ export class DispatchScheduleDto { @IsOptional() @IsISO8601() actualDepartureAt?: string; + + /** + * Loading is a manual staff decision. When present, only these bookings are + * auto-loaded at the origin; every other unloaded origin boarder is left + * behind — deallocated from its wagon and returned to the booking pool. + * Absent (older clients) = load every origin boarder, the historic behavior. + */ + @ApiProperty({ + required: false, + description: + 'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + loadedBookingIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index c4ca3da59..9c11553ff 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2370,16 +2370,22 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { - throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); - } - const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId); if (!link) { throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); } const booking = await this.bookingsRepository.findById(bookingId); + // A dispatched train may still shed a booking staff left behind at its + // boarding yard (dispatch dialog / log-pass "leave") — but never one whose + // cargo is actually on the train. + const leftBehindWhileDispatched = + schedule.status === 'DISPATCHED' && + !booking?.loadedAt && + booking?.status !== 'IN_TRANSIT'; + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status) && !leftBehindWhileDispatched) { + throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule'); + } if (booking?.isGovernment) { throw new BadRequestException( 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.', @@ -2447,6 +2453,21 @@ export class TrainSchedulingService { for (const slot of survivingSlots) { const slotAllocations = slot.allocations ?? []; if (slotAllocations.length === 0) { + // A dispatched train pinned its wagons (ASSIGNED + schedule id) at + // departure — freeing the slot must also free the physical wagon, or + // the checkpoint position-fix keeps dragging it along the corridor. + if (schedule.status === 'DISPATCHED' && slot.physicalWagonId) { + const wagon = await manager + .getRepository(Wagon) + .findOne({ where: { id: slot.physicalWagonId } }); + if (wagon && wagon.currentTrainScheduleId === scheduleId) { + await manager.getRepository(Wagon).update(wagon.id, { + currentTrainScheduleId: null, + trainSetWagonId: null, + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + }); + } + } await manager.getRepository(TrainSetWagon).delete(slot.id); continue; } @@ -2476,8 +2497,10 @@ export class TrainSchedulingService { // Freed wagons may un-full the train — re-derive the window status (this // also revives a DONE window pre-departure so the freed space is bookable - // again for import/export). - await this.bookingBatchService?.refreshWindowStatus(scheduleId); + // again for import/export). A dispatched train's window stays CLOSED. + if (schedule.status !== 'DISPATCHED') { + await this.bookingBatchService?.refreshWindowStatus(scheduleId); + } await this.trainCompositionRemovalLogRepository.create({ scheduleId, @@ -2842,14 +2865,35 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}, userId?: string) { + let schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } + // Loading is a manual staff decision: when the dispatch dialog sends the + // checked list, every other unloaded origin boarder is left behind — + // deallocated from its wagon and returned to the booking pool — so the + // origin auto-load below only ever touches confirmed cargo. Government + // bookings cannot be unassigned and keep the historic auto-load. + if (dto.loadedBookingIds) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await this.unloadedOriginBoarderIds(scheduleId, schedule.originStationId); + const leftBehind = candidates.filter((id) => !keep.has(id)); + for (const bookingId of leftBehind) { + await this.unassignBooking(scheduleId, bookingId, userId); + } + if (leftBehind.length) { + // Unassign deleted allocations and slots — reload the graph dispatch works on. + const reloaded = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!reloaded) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + schedule = reloaded; + } + } // Staff may record the departure after the fact — past is fine, future is not. const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date(); this.assertNotFuture(now, 'Departure time'); @@ -3071,6 +3115,34 @@ export class TrainSchedulingService { }); } + /** + * Origin boarders the dispatch dialog decides over: unloaded (no journey + * load, no workspace LOADED flag), boardable, non-government. Boardable is + * PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay + * (their charge sits on the credit ledger) yet ride from accept. + */ + private async unloadedOriginBoarderIds( + scheduleId: string, + originYardId: string, + ): Promise { + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT b.id + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id + WHERE tsb.train_schedule_id = $1 + AND tsb.deleted_at IS NULL + AND b.deleted_at IS NULL + AND b.origin_yard_id = $2 + AND b.loaded_at IS NULL + AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' + AND b.is_government = false + AND (b.status = 'PAID' + OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, + [scheduleId, originYardId], + ); + return rows.map((r) => r.id); + } + async getImportDjiboutiOperation(scheduleId: string) { const schedule = await this.getDjiboutiGatepassSchedule(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); @@ -9866,6 +9938,9 @@ export class TrainSchedulingService { loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), isGovernment: Boolean(sb.booking?.isGovernment), + // Shipping-line bookings never prepay (credit ledger) — the dispatch + // dialog needs this to know FULLY_EXECUTED means boardable for them. + shippingLineCompanyId: sb.booking?.shippingLineCompanyId ?? null, })) ?? [], // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index e208328cc..0ca021937 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -88,6 +88,7 @@ import { import { ConsolidationPartnerPanel, emptyPartnerLine, + emptyPartnerUnit, } from "./gl-booking-form/ConsolidationPartnerPanel"; import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker"; @@ -250,6 +251,17 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // The shipment request is the customer's order: container sizes/quantities + // and the billing currency are the customer's choices and stay read-only — + // GL enters only per-unit details (numbers, seals, VGM, handling). The + // server enforces the same on completion. + const requestContainersLocked = Boolean( + bookingRequest?.requestedLines?.containers?.length, + ); + const requestBulkLocked = + bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null; + const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency); + // The expired booking a Rebook is copying from (its cargo seeds the form). const { data: copyFromBooking } = useQuery({ queryKey: ["rebook-copy-from", copyFromParam], @@ -328,6 +340,39 @@ export default function GlCreateBookingForm() { const [partner, setPartner] = useState(null); const [partnerLines, setPartnerLines] = useState([]); const [partnerCargoDescription, setPartnerCargoDescription] = useState(""); + + // The partner is its own customer: if a shipment request created it, that + // request locks the partner's quantities and billing currency the same way + // this booking's request locks this side (server enforces both halves). + const { data: partnerContractRequests } = useQuery({ + queryKey: ["shipment-requests-for-contract", partner?.contractId], + queryFn: () => contractsService.listBookingRequests(partner!.contractId!), + enabled: Boolean(partner?.contractId), + }); + const partnerRequest = + (partner && + partnerContractRequests?.find( + (r) => r.createdBookingId === partner.id, + )) || + null; + const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length); + + // Seed (and lock) the partner's lines from its request once it loads. + useEffect(() => { + const requested = partnerRequest?.requestedLines?.containers; + if (!partner || !requested?.length) return; + setPartnerLines( + requested.map((c) => ({ + containerSize: c.containerSize, + quantity: String(Math.max(1, c.quantity)), + hazardousQuantity: "0", + reeferQuantity: "0", + returnQuantity: "0", + units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit), + })), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [partner?.id, partnerRequest?.id]); const seededRef = useRef(false); const returnSeededRef = useRef(false); @@ -490,6 +535,14 @@ export default function GlCreateBookingForm() { if (bookingRequest.contractRouteId) setContractRouteId(bookingRequest.contractRouteId); if (bookingRequest.notes) setNotes(bookingRequest.notes); + // Currency is the customer's choice on the request — seed it here; the + // selector below is disabled while the request specifies one. + if ( + bookingRequest.paymentCurrency === "USD" || + bookingRequest.paymentCurrency === "ETB" + ) { + setPaymentCurrency(bookingRequest.paymentCurrency); + } }, [bookingRequest, prefilled]); // Rebook seed: copy the source booking's container lines once. (Bulk weight / @@ -1114,7 +1167,13 @@ export default function GlCreateBookingForm() { if (!partner || !consolidationActive) return null; const payload: Freight.CreateBookingUnderContractDto = { - paymentCurrency: effectiveCurrency, + // The partner's customer chose its own currency on its shipment request; + // only a partner without a request falls back to this booking's currency. + paymentCurrency: + partnerRequest?.paymentCurrency === "USD" || + partnerRequest?.paymentCurrency === "ETB" + ? partnerRequest.paymentCurrency + : effectiveCurrency, ...(scheduledDate ? { scheduledDate: new Date(scheduledDate).toISOString() } : {}), @@ -1663,6 +1722,12 @@ export default function GlCreateBookingForm() { label="Quantity *" min={0} value={line.quantity} + disabled={requestContainersLocked} + description={ + requestContainersLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } error={ showErrors ? (lineErrors[lineIdx]?.quantity ?? @@ -1924,6 +1989,7 @@ export default function GlCreateBookingForm() { showReefer={Boolean(contract.isReefer)} showErrors={showErrors} error={partnerError} + lockQuantities={partnerLocked} /> ) : null} @@ -1946,6 +2012,12 @@ export default function GlCreateBookingForm() { placeholder="e.g. 1200" min={0} step={0.01} + disabled={requestBulkLocked} + description={ + requestBulkLocked + ? "Requested by the customer — quantity cannot be changed." + : undefined + } value={bulk.cargoWeightTons} error={ showErrors && bulkUom === "PER_TON" @@ -2147,14 +2219,16 @@ export default function GlCreateBookingForm() { Billing currency - {isImport - ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." - : "Shipments are invoiced in ETB."} + {requestCurrencyLocked + ? "The customer chose the billing currency on the shipment request — it cannot be changed." + : isImport + ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx index e52cce097..a3be22ea1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/ConsolidationPartnerPanel.tsx @@ -86,6 +86,11 @@ interface Props { /** Surface field errors only after the operator tried to continue. */ showErrors: boolean; error?: string; + /** + * The partner's shipment request fixed its sizes/quantities — the quantity + * fields render read-only and GL enters only per-unit details. + */ + lockQuantities?: boolean; } export function ConsolidationPartnerPanel({ @@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({ showReefer, showErrors, error, + lockQuantities, }: Props) { const patchLine = (index: number, patch: Partial) => { onLinesChange( @@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({ label="Quantity *" min={0} value={line.quantity} + disabled={lockQuantities} + description={ + lockQuantities + ? "Requested by the partner's customer — quantity cannot be changed." + : undefined + } onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })} // Sync off the typed value, not the captured `line` — that snapshot // still holds the pre-edit quantity and would write it back. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx index c35e87a5a..b2de28f3b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/LogPassYardWorkModal.tsx @@ -115,6 +115,7 @@ export function LogPassYardWorkModal({ const { toast } = useToast(); const { user } = useAuth(); const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load); + const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update); const [justLogged, setJustLogged] = useState(false); // When the train was here — defaults to now, past allowed (recorded after the fact). const [passAt, setPassAt] = useState(null); @@ -134,6 +135,10 @@ export function LogPassYardWorkModal({ api.trainScheduling.recordCheckpoint.mutationOptions(), ); const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions()); + // "Leave behind": the cargo is not on the train — unassign frees its wagons + // and returns the booking to the pool for a later schedule. Reversible (the + // booking can be re-assigned), so no extra confirm step. + const leave = useMutation(api.trainScheduling.unassignBooking.mutationOptions()); const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId); const boarders: YardWorkBookingRow[] = yard?.toLoad ?? []; @@ -196,6 +201,28 @@ export function LogPassYardWorkModal({ ); }; + const doLeave = (row: YardWorkBookingRow) => { + leave.mutate( + { id: scheduleId, bookingId: row.id }, + { + onSuccess: () => { + toast({ + title: `${row.reference ?? "Booking"} left behind`, + description: + "Removed from this train — wagons freed, booking returned to the pool for a later schedule.", + }); + void yardWorkQuery.refetch(); + }, + onError: (err) => + toast({ + title: "Could not leave booking behind", + description: parseError(err, "Please try again"), + variant: "destructive", + }), + }, + ); + }; + const hasWork = boarders.length > 0 || arrivals.length > 0; return ( @@ -355,30 +382,54 @@ export function LogPassYardWorkModal({ {!row.loadedAt ? ( - - - + + + + + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx index eac18ca2a..83a20d20f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx @@ -1,7 +1,9 @@ import { useMemo, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Badge, + Button, Card, Code, Group, @@ -42,6 +44,26 @@ const OUTCOME_OPTIONS = [ { value: "false", label: "Failed" }, ]; +/** + * Entity type → detail page for that record. Drives the row's "Go" button; + * types without a detail page (Wagon, Locomotive, …) simply have no button. + */ +const ENTITY_ROUTES: Record string> = { + Booking: (id) => `/dashboard/booking-requests/${id}`, + Contract: (id) => `/dashboard/contract-requests/${id}`, + Schedule: (id) => `/dashboard/operations/train-scheduling-v2/${id}`, + "Train Schedule": (id) => `/dashboard/operations/train-scheduling-v2/${id}`, + Train: (id) => `/dashboard/trains/${id}`, + "Train Build": (id) => `/dashboard/trains/${id}`, + "EIMS Invoice": (id) => `/dashboard/invoices/${id}`, + Payment: (id) => `/dashboard/invoices/${id}`, + Vehicle: (id) => `/dashboard/vehicles/${id}`, + Company: (id) => `/dashboard/customers/${id}`, +}; + +const entityRoute = (log: AuditLog): string | null => + log.resourceId ? (ENTITY_ROUTES[log.type]?.(log.resourceId) ?? null) : null; + /** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */ const startOfDay = (date: string) => `${date}T00:00:00.000Z`; const endOfDay = (date: string) => `${date}T23:59:59.999Z`; @@ -49,13 +71,20 @@ const endOfDay = (date: string) => `${date}T23:59:59.999Z`; const formatTimestamp = (value: string) => new Date(value).toLocaleString(); const AuditLogsPage = () => { + const navigate = useNavigate(); + // Entity pages deep-link here as /dashboard/audit-logs?type=Booking&resourceId= + // to show one record's full history with the filters already applied. + const [searchParams] = useSearchParams(); + // Server-side filters. Unlike most freight lists (which filter an // already-fetched array via useListControls), audit_logs is append-only and // grows without bound, so filtering and paging both happen in the API. - const [search, setSearch] = useState(""); + const [search, setSearch] = useState(searchParams.get("q") ?? ""); const [dateFrom, setDateFrom] = useState(null); const [dateTo, setDateTo] = useState(null); - const [type, setType] = useState(null); + const [type, setType] = useState(searchParams.get("type")); + const [resourceId] = useState(searchParams.get("resourceId")); + const [action, setAction] = useState(null); const [method, setMethod] = useState(null); const [outcome, setOutcome] = useState(null); const [selected, setSelected] = useState(null); @@ -69,13 +98,16 @@ const AuditLogsPage = () => { type: type ?? undefined, method: (method as AuditMethod | null) ?? undefined, isSuccess: outcome === null ? undefined : outcome === "true", - // The API filters by record id; the search box is the natural place to - // paste one when tracing what happened to a specific contract/booking. - resourceId: search.trim() || undefined, + // Free-text: matches reference (booking/schedule/train number), record + // id, staff name and action title server-side. + q: search.trim() || undefined, + title: action ?? undefined, + // Set only via deep link from an entity page's "History" button. + resourceId: resourceId ?? undefined, from: dateFrom ? startOfDay(dateFrom) : undefined, to: dateTo ? endOfDay(dateTo) : undefined, }), - [pagination, type, method, outcome, search, dateFrom, dateTo], + [pagination, type, method, outcome, search, action, resourceId, dateFrom, dateTo], ); const logsQuery = useQuery({ @@ -88,12 +120,17 @@ const AuditLogsPage = () => { queryFn: () => auditLogsService.types(), }); + const actionsQuery = useQuery({ + queryKey: ["audit-logs", "actions"], + queryFn: () => auditLogsService.actions(), + }); + const rows = logsQuery.data?.items ?? []; const totalCount = logsQuery.data?.meta.total ?? 0; const pageCount = logsQuery.data?.meta.totalPages ?? 0; const hasFilters = Boolean( - search || dateFrom || dateTo || type || method || outcome, + search || dateFrom || dateTo || type || action || method || outcome, ); const resetFilters = () => { @@ -101,6 +138,7 @@ const AuditLogsPage = () => { setDateFrom(null); setDateTo(null); setType(null); + setAction(null); setMethod(null); setOutcome(null); setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); @@ -135,7 +173,7 @@ const AuditLogsPage = () => { { searchable w={200} /> + { Action Entity + Reference Method User Outcome When + @@ -214,6 +264,11 @@ const AuditLogsPage = () => { {log.type} + + + {log.reference || "—"} + + {log.method} @@ -250,6 +305,21 @@ const AuditLogsPage = () => { {formatTimestamp(log.createdAt)} + + {entityRoute(log) ? ( + + ) : null} + ))} @@ -277,6 +347,7 @@ const AuditLogsPage = () => { + diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx index 3aa6a785b..4ca8100a2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -74,6 +74,23 @@ interface WagonCancellation { }; } +interface RebookPartnerCandidate { + id: string; + reference: string; + companyName: string | null; + status: string; + scheduledDate: string | null; + ft20Quantity: number; +} + +/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */ +const hasOddFt20 = (r: WagonCancellation): boolean => + Object.entries(r.cancelledQuantities?.bySize ?? {}) + .filter(([size]) => parseInt(size, 10) === 20) + .reduce((sum, [, qty]) => sum + Number(qty || 0), 0) % + 2 === + 1; + /** Editable rebook unit — prefilled from the cancelled snapshot. */ interface RebookUnitDraft { containerSize: string; @@ -147,10 +164,12 @@ export default function WagonCancellationsPage() { ); const [rebooking, setRebooking] = useState(null); const [rebookDate, setRebookDate] = useState(null); + const [rebookPartnerId, setRebookPartnerId] = useState(null); const [rebookDrafts, setRebookDrafts] = useState([]); const openRebook = (r: WagonCancellation) => { setRebooking(r); setRebookDate(null); + setRebookPartnerId(null); setRebookDrafts( (r.cancelledQuantities?.units ?? []).map((u) => ({ containerSize: u.containerSize, @@ -179,9 +198,30 @@ export default function WagonCancellationsPage() { api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, { scheduledDate: toDayString(rebookDate!), ...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}), + ...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}), }), }); + // Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick + // the odd partner booking riding the chosen day. It ships once that partner pays. + const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false; + const rebookPartners = useQuery({ + queryKey: [ + "wagon-cancellations", + rebooking?.id, + "rebook-partners", + rebookDate ? toDayString(rebookDate) : null, + ], + enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate), + queryFn: async () => { + const res = await api.get( + `/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`, + { params: { scheduledDate: toDayString(rebookDate!) } }, + ); + return res.data; + }, + }); + const resetPage = () => setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); @@ -301,11 +341,12 @@ export default function WagonCancellationsPage() { const r = row.original; const showVoid = r.status === "FEE_PENDING" && canVoid; // Customs credits are GL's to rebook; non-customs ones the customer - // rebooks from the portal. + // rebooks from the portal — EXCEPT odd-20ft credits: those must be + // re-paired with a partner booking, which only GL can pick. const showRebook = r.status === "CREDIT_AVAILABLE" && canRebook && - Boolean(r.booking?.customsClearingEnabled) && + (Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) && Number(r.creditAmount) > 0; if (!showVoid && !showRebook) return null; return ( @@ -495,9 +536,43 @@ export default function WagonCancellationsPage() { label="Shipment day" placeholder="Pick the day" value={rebookDate} - onChange={(v) => setRebookDate(v ? new Date(v) : null)} + onChange={(v) => { + setRebookDate(v ? new Date(v) : null); + setRebookPartnerId(null); + }} radius="md" /> + {rebookNeedsPartner && ( + setOriginStationId(e.target.value)}> + + {stations.map((s) => ( + + ))} + + +
+ + +
+
+ + +
+
+ + setAdultCount(e.target.value === '' ? 0 : Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ + setChildCount(e.target.value === '' ? 0 : Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+ + +
+ +
+ {(['LOCAL', 'INTERNATIONAL'] as const).map((tier) => ( + + ))} +
+

+ The whole group is quoted and charged at one uniform rate — pick the tier that matches most of the roster. + A passenger whose ID document implies the other tier will be flagged when you upload the passenger list. +

+
+ + {originStationId && destinationStationId && travelDate && totalPassengers > 0 && ( +
+ + Searching for {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}) +
+ )} + + {searchTouched && !searchValid && ( +

+ {totalPassengers <= 0 + ? 'Enter at least one adult or child.' + : originStationId && originStationId === destinationStationId + ? 'Origin and destination must be different.' + : 'Fill in origin, destination, and travel date.'} +

+ )} + + + Search Availability + + + )} + + {/* ── Step 2: Results ────────────────────────────────────────────── */} + {step === 'results' && ( +
+ + + {searchMutation.isPending && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+
+ + +
+ +
+
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+
+ ))} +
+ )} + + {searchMutation.isError && ( +
+ + + {(searchMutation.error as any)?.response?.data?.message ?? 'Search failed. Please try again.'} + + searchMutation.mutate()}>Retry +
+ )} + + {searchMutation.isSuccess && searchMutation.data.outbound.length === 0 && ( +
+ +

No schedules found for this search.

+

{emptySearchMessage(searchMutation.data.outboundReason)}

+
+ )} + + {searchMutation.isSuccess && (searchMutation.data.alternativeOutbound?.length ?? 0) > 0 && ( +
+

+ Nearby schedules for the same route +

+ {searchMutation.data!.alternativeOutbound!.map((schedule) => ( + + ))} +
+ )} + + {(searchMutation.data?.outbound ?? []).map((schedule) => ( + + ))} +
+ )} + + {/* ── Step 3: Passenger Information ─────────────────────────────── */} + {step === 'passengers' && selectedSchedule && selectedClass && ( +
+ + +
+
+
+ +
+

Passenger Information

+
+

+ Download the template, fill in one row per passenger, then upload the completed file. + Need exactly {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} ({adultCount} Adult{adultCount === 1 ? '' : 's'}{childCount > 0 ? `, ${childCount} Child${childCount === 1 ? '' : 'ren'}` : ''}). +

+ +
+ + Download Passenger Template + +
+ +
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {fileName ? ( + <> + +

{fileName}

+ + + ) : ( + <> + +

{parsing ? 'Reading file…' : 'Drag & drop the completed template here'}

+

or click to browse — .xlsx or .xls, using the downloaded template

+ + )} + +
+ + {fileErrors.length > 0 && ( +
+ {fileErrors.map((e, i) =>

{e}

)} +
+ )} + + {countMismatch && ( +
+ Expected {totalPassengers} passengers ({adultCount} Adult{adultCount === 1 ? '' : 's'}, {childCount} Child{childCount === 1 ? '' : 'ren'}) — + the uploaded file has {passengerRows.length} ({uploadedAdults} Adult{uploadedAdults === 1 ? '' : 's'}, {uploadedChildren} Child{uploadedChildren === 1 ? '' : 'ren'}). + Fix the file to match and re-upload. +
+ )} +
+ + {passengerRows.length > 0 && ( +
+
+

+ Passenger Preview ({passengerRows.length}) +

+
+
+ + + + {['Row', 'Name', 'DOB', 'Type', 'ID Document', 'Nationality', 'Status'].map((h) => ( + + ))} + + + + {passengerRows.map((row) => ( + 0 ? 'bg-red-50/60 dark:bg-red-950/20' : undefined}> + + + + + + + + + ))} + +
{h}
{row.rowNumber}{row.fullName || '—'}{row.dateOfBirth || '—'}{row.passengerType || '—'}{row.idDocumentType || '—'}{row.nationality || '—'} + {row.errors.length === 0 ? ( + + Valid + {row.warnings.length > 0 && ({row.warnings.length} warning{row.warnings.length === 1 ? '' : 's'})} + + ) : ( + + + {row.errors.join('; ')} + + )} + {row.errors.length === 0 && row.warnings.length > 0 && ( +

{row.warnings.join('; ')}

+ )} +
+
+
+ + {passengersValid && } + {passengersValid ? 'All passengers valid — ready to continue.' : 'Fix the issues above before continuing.'} + + + Continue to Seat Assignment + +
+
+ )} + + {assignError && ( +
+ {assignError} +
+ )} +
+ )} + + {/* ── Step 4: Seat Assignment Preview + Step 5: Confirm ─────────── */} + {step === 'confirm' && hold && selectedSchedule && selectedClass && ( +
+
+
+
+
+ +
+

+ Seats Assigned Automatically +

+
+ Held for {Math.floor(hold.ttlSeconds / 60)}m {hold.ttlSeconds % 60}s +
+

Read-only — seats are assigned by the system, not selected manually.

+
+ {seatAssignments.map(({ row, seat }) => ( +
+
+ {seat ? (seat.seatNumber ?? seat.label ?? '?') : '—'} +
+
+

{row.fullName}

+

{row.passengerType} · {seat?.coach ?? '—'}

+
+
+ ))} +
+
+ + {bookingError && ( +
+ {bookingError} + Reassign & Retry +
+ )} + +
+
+ {totalPassengers} passenger{totalPassengers === 1 ? '' : 's'} · {selectedClass.category} · {formatCurrency(selectedClass.fareMinor * totalPassengers, selectedClass.displayCurrency)} estimated total +
+ createBookingMutation.mutate()} + > + Create Booking + +
+
+ )} + + {/* ── Step 6: Success ────────────────────────────────────────────── */} + {step === 'success' && booking && ( +
+
+
+
+
+ +
+
+

Group Booking Created

+

{booking.bookingRef}

+

Status: {booking.status}

+
+
+
+
+
+

Train

+

{booking.schedule.train.number} · {booking.schedule.train.name}

+
+
+

Travel Date

+

{formatDate(booking.schedule.departureAt)}

+
+
+

Origin → Destination

+

{booking.schedule.originStation.name} → {booking.schedule.destinationStation.name}

+
+
+

Departure → Arrival

+

{formatDateTime(booking.schedule.departureAt)} → {formatDateTime(booking.schedule.arrivalAt)}

+
+
+

Coach / Class

+

{selectedClass?.category ?? '—'}

+
+
+

Adults / Children

+

{booking.adultCount} / {booking.childCount}

+
+
+

Total Passengers

+

{booking.seats.length}

+
+
+

Total Fare

+

{formatCurrency(booking.totalMinor, booking.currency)}

+
+
+
+ +
+
+

Passenger List

+
+
+ {booking.seats.map((s, i) => ( +
+
+ {s.seat?.seatNumber ?? '?'} +
+
+

{s.passengerName}

+

+ {s.passengerCategory} · {seatTypeLabel(s.seat?.bedPosition)} · Seat {s.seat?.seatNumber ?? '—'} · Coach {s.seat?.coach?.number} +

+
+
+ ))} +
+
+ + {/* ── Pay now ─────────────────────────────────────────────────── */} + {!paymentResult && ( +
+

+ Collect Payment +

+ {enabledPaymentMethods.length === 0 ? ( +

Loading payment options…

+ ) : ( + <> +
+ {enabledPaymentMethods.map((m) => { + const Icon = paymentMethodIcon(m.type); + const active = selectedPaymentType === m.type; + return ( + + ); + })} +
+ {paymentError && ( +
+ {paymentError} +
+ )} + initiatePaymentMutation.mutate()} + disabled={!selectedPaymentType || initiatePaymentMutation.isPending} + > + {initiatePaymentMutation.isPending ? ( + + Initiating… + + ) : ( + 'Initiate Payment' + )} + + + )} +
+ )} + + {paymentResult && ( +
+

+ Payment {paymentResult.status === 'SUCCEEDED' ? 'Complete' : 'Initiated'} +

+ + {paymentResult.status === 'SUCCEEDED' && ( +
+ Payment succeeded. +
+ )} + + {(paymentResult.status === 'FAILED' || paymentResult.status === 'CANCELLED') && ( +
+ + {paymentResult.failureMessage ?? 'Payment was not completed.'} +
+ )} + + {paymentResult.clientAction?.type === 'SHOW_BILL_REFERENCE' && ( +
+

+ {paymentResult.clientAction.instructions ?? + 'Pay this bill at any CBE branch, the CBE Birr app, mobile banking or USSD.'} +

+
+ + {paymentResult.clientAction.billReference} + + { + if (paymentResult.clientAction?.billReference) { + await navigator.clipboard.writeText(paymentResult.clientAction.billReference); + setBillCopied(true); + setTimeout(() => setBillCopied(false), 2000); + } + }} + > + {billCopied ? 'Copied' : 'Copy'} + +
+
+

Amount: {formatCurrency(booking.totalMinor, booking.currency)}

+ {paymentResult.clientAction.expiresAt && ( +

Pay before: {formatDateTime(paymentResult.clientAction.expiresAt)}

+ )} +
+
+ )} + + {paymentResult.clientAction && !['SHOW_BILL_REFERENCE'].includes(paymentResult.clientAction.type) && ( +

+ {paymentResult.clientAction.message ?? `Next step: ${paymentResult.clientAction.type}.`} +

+ )} + +

Payment reference: {paymentResult.intentId}

+ + +
+ )} + +
+ + View Booking + + +
+
+ )} + + ); +} + +export default function GroupBookingPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 3fc80428b..909361c46 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -39,6 +39,7 @@ import { Activity, Smartphone, Layers, + UsersRound, } from 'lucide-react'; import { useAuthStore } from '@/lib/auth-store'; import { cn } from '@/lib/utils'; @@ -63,6 +64,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ title: 'Operations', items: [ { name: 'Bookings', href: '/bookings', icon: Ticket, permission: PERMS.bookings.view }, + { name: 'Group Booking', href: '/group-booking', icon: UsersRound, permission: PERMS.bookings.manage }, { name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view }, { name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view }, { name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.manage }, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts b/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts new file mode 100644 index 000000000..0effa7d5b --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/api/group-booking.ts @@ -0,0 +1,219 @@ +import { apiClient } from '@/lib/api-client'; + +// ── Search (POST /search) ────────────────────────────────────────────────── + +export interface SearchTripsRequest { + originStationId: string; + destinationStationId: string; + date: string; + adultCount: number; + childCount?: number; + journeyType: 'ONE_WAY'; + /** Drives which fare tier (Local vs International) gets quoted — see fareTier in the page component. */ + nationality?: string; +} + +export interface ScheduleClassOption { + name: string; + baseFareMinor: number; + displayCurrency: string; + displayAmountMinor: number; + available: number; +} + +export interface ScheduleCoachType { + coachTypeId: string; + coachTypeName: string; + coachTypeCode: string; + coachId: string; + classes: ScheduleClassOption[]; +} + +export interface ScheduleResult { + type: 'DIRECT'; + scheduleId: string; + trainNumber: string; + trainName: string; + origin: { id: string; code: string; name: string; city: string; sequence: number }; + destination: { id: string; code: string; name: string; city: string; sequence: number }; + departureAt: string; + arrivalAt: string; + durationMinutes: number; + status: string; + hasAvailability: boolean; + displayCurrency: string; + coachTypes: ScheduleCoachType[]; +} + +export type SearchEmptyReasonCode = + | 'NO_ROUTE' + | 'NO_SCHEDULE_ON_DATE' + | 'CANCELLED' + | 'PACKAGE_ONLY' + | 'CHECKIN_CLOSED' + | 'FULLY_BOOKED'; + +/** Structured, not a string — always render via a code→message lookup, never directly. */ +export interface SearchEmptyReason { + code: SearchEmptyReasonCode; + originStationName: string; + destinationStationName: string; +} + +export interface SearchTripsResponse { + journeyType: string; + outbound: ScheduleResult[]; + requestedDate: string; + outboundReason?: SearchEmptyReason; + /** Nearby schedules for the same station pair on a different date, offered when `outbound` is empty. */ + alternativeOutbound?: ScheduleResult[]; +} + +// ── Seat classes (GET /seat-classes) ─────────────────────────────────────── + +export interface SeatClassOption { + id: string; + name: string; +} + +// ── Auto-assign + hold (POST /seats/auto-assign-hold) ───────────────────── + +export interface AutoAssignHoldRequest { + scheduleId: string; + originStationId: string; + destinationStationId: string; + seatClassName: string; + adultCount: number; + childCount?: number; +} + +export interface HeldPassengerSeat { + passengerId: string; + seat: { + id: string; + label?: string; + seatNumber?: string; + coach?: string; + row?: number; + col?: string; + }; +} + +export interface AutoAssignHoldResponse { + holdId: string; + expiresAt: string; + ttlSeconds: number; + schedule: { id: string; trainNumber: string; trainName: string; departureAt: string; arrivalAt: string } | null; + passengers: HeldPassengerSeat[]; +} + +// ── Group booking creation (POST /bookings/group) ────────────────────────── + +export interface GroupBookingPassengerInput { + seatId: string; + passengerName: string; + dateOfBirth: string; + idDocumentType: 'NATIONAL_ID' | 'PASSPORT' | 'DRIVING_LICENSE' | 'OTHER'; + idDocumentNumber?: string; + passportNumber?: string; + passportCountry?: string; + nationality?: string; + phone?: string; + email?: string; +} + +export interface CreateGroupBookingRequest { + scheduleId: string; + holdId: string; + originStationId: string; + destinationStationId: string; + seatClassId: string; + bookingType: 'ONE_WAY'; + passengers: GroupBookingPassengerInput[]; +} + +export interface GroupBookingSeat { + seatId: string; + passengerName: string; + passengerCategory: 'ADULT' | 'CHILD'; + seat: { seatNumber: string; bedPosition?: string | null; coach: { number: string } }; +} + +export interface CreateGroupBookingResponse { + id: string; + bookingRef: string; + status: string; + totalMinor: number; + currency: string; + adultCount: number; + childCount: number; + seats: GroupBookingSeat[]; + schedule: { + departureAt: string; + arrivalAt: string; + train: { number: string; name: string }; + originStation: { name: string }; + destinationStation: { name: string }; + }; +} + +// ── Payment (GET /payments/methods, POST /payments/initiate) ─────────────── + +export type PaymentMethodType = + | 'TELEBIRR' | 'CBE_BIRR' | 'EBIRR' | 'WAAFI' | 'DMONEY' | 'CAC_BANK' | 'CARD' | 'WALLET' | 'CBE_BILL'; + +export interface SupportedPaymentMethod { + id: string; + type: PaymentMethodType; + displayName: string; + region: string; + currency: string; + enabled: boolean; +} + +export interface InitiatePaymentRequest { + bookingId: string; + method: PaymentMethodType; + paymentMethodId?: string; + platform?: 'web' | 'mobile' | 'inapp'; +} + +export interface PaymentClientAction { + type: 'REDIRECT' | 'LAUNCH_APP' | 'INVOKE_BRIDGE' | 'COLLECT_OTP' | 'AWAIT_PUSH' | 'SHOW_BILL_REFERENCE'; + url?: string; + /** Set when type=SHOW_BILL_REFERENCE (CBE bill payment) — the number the payer enters at any CBE channel. */ + billReference?: string; + instructions?: string; + expiresAt?: string; + message?: string; + payerAccountMasked?: string; +} + +export interface InitiatePaymentResponse { + intentId: string; + status: string; + clientAction?: PaymentClientAction; + merchantOrderId?: string; + failureCode?: string; + failureMessage?: string; + sessionExpiresAt?: string; + paymentDeadline?: string; +} + +export const groupBookingApi = { + searchTrips: (dto: SearchTripsRequest) => + apiClient.post('/search', dto), + + getSeatClasses: () => apiClient.get('/seat-classes'), + + autoAssignHold: (dto: AutoAssignHoldRequest) => + apiClient.post('/seats/auto-assign-hold', dto), + + createGroupBooking: (dto: CreateGroupBookingRequest) => + apiClient.post('/bookings/group', dto), + + getPaymentMethods: () => apiClient.get('/payments/methods'), + + initiatePayment: (dto: InitiatePaymentRequest) => + apiClient.post('/payments/initiate', dto), +}; diff --git a/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts b/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts new file mode 100644 index 000000000..4e2f20964 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/export/passenger-template.ts @@ -0,0 +1,149 @@ +import ExcelJS from 'exceljs'; + +// Brand palette — matches finance-workbook.ts / ActionButton's primary variant, kept as a +// small local copy rather than a shared import since these are two unrelated export domains. +const BRAND = 'FF14714C'; +const BRAND_TINT = 'FFEAF5EF'; +const INK = 'FF1F2937'; +const MUTED = 'FF6B7280'; +const BORDER = 'FFE2E5E1'; +const WHITE = 'FFFFFFFF'; + +const THIN_BORDER: Partial = { + top: { style: 'thin', color: { argb: BORDER } }, + left: { style: 'thin', color: { argb: BORDER } }, + bottom: { style: 'thin', color: { argb: BORDER } }, + right: { style: 'thin', color: { argb: BORDER } }, +}; + +/** Column order is the contract — passenger-excel.ts reads by this same header order. */ +export const PASSENGER_TEMPLATE_COLUMNS = [ + 'Full Name', + 'Date of Birth (YYYY-MM-DD)', + 'Passenger Type', + 'ID Document Type', + 'ID Document Number', + 'Passport Number', + 'Passport Country', + 'Nationality', + 'Phone', + 'Email', +] as const; + +const REQUIRED_ROW = 200; + +export interface PassengerTemplateInput { + trainNumber: string; + origin: string; + destination: string; + travelDate: string; + seatClassName: string; + adultCount: number; + childCount: number; +} + +export async function buildPassengerTemplate(input: PassengerTemplateInput): Promise { + const wb = new ExcelJS.Workbook(); + wb.creator = 'EDR Passenger Backoffice'; + wb.created = new Date(); + + const ws = wb.addWorksheet('Passengers', { views: [{ state: 'frozen', ySplit: 5 }] }); + ws.columns = PASSENGER_TEMPLATE_COLUMNS.map((h) => ({ width: h.length < 14 ? 18 : h.length + 4 })); + + // ── Title + trip context banner ────────────────────────────────────────── + ws.mergeCells(1, 1, 1, PASSENGER_TEMPLATE_COLUMNS.length); + const title = ws.getCell(1, 1); + title.value = 'EDR Group Booking — Passenger Template'; + title.font = { bold: true, size: 16, color: { argb: WHITE } }; + title.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } }; + title.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; + ws.getRow(1).height = 30; + for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) ws.getCell(1, c).fill = title.fill; + + ws.mergeCells(2, 1, 2, PASSENGER_TEMPLATE_COLUMNS.length); + const subtitle = ws.getCell(2, 1); + subtitle.value = `Train ${input.trainNumber} · ${input.origin} → ${input.destination} · ${input.travelDate} · ${input.seatClassName}`; + subtitle.font = { size: 11, color: { argb: INK } }; + subtitle.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; + ws.getRow(2).height = 20; + + ws.mergeCells(3, 1, 3, PASSENGER_TEMPLATE_COLUMNS.length); + const requirement = ws.getCell(3, 1); + const total = input.adultCount + input.childCount; + requirement.value = `Fill in exactly ${total} passenger row${total === 1 ? '' : 's'} below — ${input.adultCount} Adult${input.adultCount === 1 ? '' : 's'} + ${input.childCount} Child${input.childCount === 1 ? '' : 'ren'}. One row per passenger, in any order.`; + requirement.font = { italic: true, size: 10, color: { argb: MUTED } }; + requirement.alignment = { vertical: 'middle', horizontal: 'left', indent: 1 }; + ws.getRow(3).height = 18; + + ws.mergeCells(4, 1, 4, PASSENGER_TEMPLATE_COLUMNS.length); + const instructions = ws.getCell(4, 1); + instructions.value = + 'Columns marked * are required. Date of Birth must be YYYY-MM-DD and not in the future — it determines Adult/Child pricing (under 5 = Child). ' + + 'ID Document Type must be one of: NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER. Do not rename or reorder columns.'; + instructions.font = { size: 9, color: { argb: MUTED } }; + instructions.alignment = { vertical: 'middle', horizontal: 'left', indent: 1, wrapText: true }; + ws.getRow(4).height = 28; + + // ── Header row ──────────────────────────────────────────────────────────── + const headerRow = ws.getRow(5); + const requiredCols = new Set([0, 1, 2, 3]); // Full Name, DOB, Passenger Type, ID Document Type + PASSENGER_TEMPLATE_COLUMNS.forEach((h, i) => { + const cell = headerRow.getCell(i + 1); + cell.value = requiredCols.has(i) ? `${h} *` : h; + cell.font = { bold: true, color: { argb: WHITE }, size: 11 }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND } }; + cell.alignment = { vertical: 'middle', horizontal: 'left', wrapText: true }; + cell.border = THIN_BORDER; + }); + headerRow.height = 30; + + // ── One filled example row so the format is obvious at a glance ────────── + const example = ws.getRow(6); + const exampleValues = [ + 'Abebe Kebede', + '1990-05-15', + 'Adult', + 'NATIONAL_ID', + 'ET123456789', + '', + '', + 'Ethiopian', + '+251911234567', + 'abebe@example.com', + ]; + exampleValues.forEach((v, i) => { + const cell = example.getCell(i + 1); + cell.value = v; + cell.font = { italic: true, color: { argb: MUTED } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND_TINT } }; + cell.border = THIN_BORDER; + }); + + // ── Blank rows with borders + dropdown validation for Passenger Type / ID Document Type ── + // exceljs's types only expose per-cell `cell.dataValidation`, not a worksheet-level range API. + for (let r = 7; r <= REQUIRED_ROW; r++) { + const row = ws.getRow(r); + for (let c = 1; c <= PASSENGER_TEMPLATE_COLUMNS.length; c++) { + row.getCell(c).border = THIN_BORDER; + } + row.getCell(3).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"Adult,Child"'], + showErrorMessage: true, + errorTitle: 'Invalid Passenger Type', + error: 'Choose Adult or Child.', + }; + row.getCell(4).dataValidation = { + type: 'list', + allowBlank: true, + formulae: ['"NATIONAL_ID,PASSPORT,DRIVING_LICENSE,OTHER"'], + showErrorMessage: true, + errorTitle: 'Invalid ID Document Type', + error: 'Choose NATIONAL_ID, PASSPORT, DRIVING_LICENSE, or OTHER.', + }; + } + + const buffer = await wb.xlsx.writeBuffer(); + return new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts b/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts new file mode 100644 index 000000000..2a37d3a80 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/import/passenger-excel.ts @@ -0,0 +1,229 @@ +import ExcelJS from 'exceljs'; + +const VALID_ID_TYPES = ['NATIONAL_ID', 'PASSPORT', 'DRIVING_LICENSE', 'OTHER']; + +/** + * Mirrors guest-booking.service.ts's per-passenger nationality inference exactly (NATIONAL_ID + * always forces 'Ethiopian'; PASSPORT falls back to Djiboutian/Other by passport country), so a + * row that will actually be priced at a different fare tier than the one quoted at search time + * is caught here instead of silently mispricing the group later. + */ +function inferredFareTier(docType: string, nationality: string, passportCountry: string): 'LOCAL' | 'INTERNATIONAL' { + const natUpper = nationality.trim().toUpperCase(); + const isEthiopian = natUpper === 'ETHIOPIAN' || docType === 'NATIONAL_ID'; + let resolved = nationality; + if (isEthiopian && docType === 'NATIONAL_ID') resolved = 'Ethiopian'; + else if (!isEthiopian && docType === 'PASSPORT') resolved = nationality || (passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + else if (isEthiopian && docType === 'PASSPORT') resolved = 'Ethiopian'; + const resolvedUpper = resolved.trim().toUpperCase(); + return resolvedUpper === 'ETHIOPIAN' || resolvedUpper === 'DJIBOUTIAN' ? 'LOCAL' : 'INTERNATIONAL'; +} + +export interface ParsedPassengerRow { + /** 1-based row number in the sheet, for error messages ("row 8"). */ + rowNumber: number; + fullName: string; + dateOfBirth: string; // normalized YYYY-MM-DD, empty if invalid/missing + passengerType: 'Adult' | 'Child' | ''; + idDocumentType: string; + idDocumentNumber: string; + passportNumber: string; + passportCountry: string; + nationality: string; + phone: string; + email: string; + errors: string[]; + warnings: string[]; +} + +export interface ParsePassengerExcelResult { + rows: ParsedPassengerRow[]; + /** Structural problems (wrong file, missing columns) — nothing in `rows` can be trusted if this is non-empty. */ + fileErrors: string[]; +} + +function cellText(row: ExcelJS.Row, colIndex: number): string { + if (colIndex < 1) return ''; + const v = row.getCell(colIndex).value; + if (v === null || v === undefined) return ''; + if (v instanceof Date) return v.toISOString().split('T')[0]; + if (typeof v === 'object') { + const anyV = v as any; + if (typeof anyV.text === 'string') return anyV.text.trim(); + if (anyV.result !== undefined) return String(anyV.result).trim(); + if (anyV.richText) return anyV.richText.map((t: any) => t.text).join('').trim(); + } + return String(v).trim(); +} + +/** Strips a trailing " *" (required-column marker) so header matching survives the template's own formatting. */ +function normalizeHeader(h: string): string { + return h.replace(/\s*\*\s*$/, '').trim(); +} + +export async function parsePassengerExcel(file: File, quotedFareTier?: 'LOCAL' | 'INTERNATIONAL'): Promise { + const buffer = await file.arrayBuffer(); + const wb = new ExcelJS.Workbook(); + try { + await wb.xlsx.load(buffer); + } catch { + return { + rows: [], + fileErrors: ['Could not read this file. Make sure it is a valid .xlsx or .xls file exported from the downloaded template.'], + }; + } + + const ws = wb.worksheets[0]; + if (!ws) return { rows: [], fileErrors: ['The workbook has no sheets.'] }; + + // Locate the header row by scanning the first several rows for one starting with "Full Name" — + // the template puts it at row 5 (after the title/instruction banners), but scanning is more + // forgiving of an edited file than hardcoding a row number. + let headerRowIndex = -1; + let headers: string[] = []; + for (let r = 1; r <= 10; r++) { + const row = ws.getRow(r); + const values: string[] = []; + for (let c = 1; c <= 12; c++) values.push(normalizeHeader(cellText(row, c))); + if (values.some((v) => v.toLowerCase().startsWith('full name'))) { + headerRowIndex = r; + headers = values; + break; + } + } + if (headerRowIndex === -1) { + return { + rows: [], + fileErrors: ['Could not find the expected header row (starting with "Full Name"). Please use the downloaded template without changing its structure.'], + }; + } + + const colFor = (label: string) => headers.findIndex((h) => h.toLowerCase().startsWith(label.toLowerCase())) + 1; + const idx = { + fullName: colFor('Full Name'), + dob: colFor('Date of Birth'), + type: colFor('Passenger Type'), + docType: colFor('ID Document Type'), + docNumber: colFor('ID Document Number'), + passportNumber: colFor('Passport Number'), + passportCountry: colFor('Passport Country'), + nationality: colFor('Nationality'), + phone: colFor('Phone'), + email: colFor('Email'), + }; + if (idx.fullName < 1 || idx.dob < 1 || idx.type < 1 || idx.docType < 1) { + return { + rows: [], + fileErrors: ['One or more required columns (Full Name, Date of Birth, Passenger Type, ID Document Type) are missing. Please use the downloaded template.'], + }; + } + + const rows: ParsedPassengerRow[] = []; + const lastRow = ws.actualRowCount || ws.rowCount; + + for (let r = headerRowIndex + 1; r <= lastRow; r++) { + const row = ws.getRow(r); + const fullName = cellText(row, idx.fullName); + const dobRaw = cellText(row, idx.dob); + const typeRaw = cellText(row, idx.type); + const docTypeRaw = cellText(row, idx.docType).toUpperCase(); + const docNumber = cellText(row, idx.docNumber); + const passportNumber = cellText(row, idx.passportNumber); + const passportCountry = cellText(row, idx.passportCountry); + const nationality = cellText(row, idx.nationality); + const phone = cellText(row, idx.phone); + const email = cellText(row, idx.email); + + // Skip fully blank trailing rows (the template pre-formats borders down to row 200). + if (![fullName, dobRaw, typeRaw, docTypeRaw, docNumber, passportNumber, nationality, phone, email].some((v) => v)) { + continue; + } + + const errors: string[] = []; + const warnings: string[] = []; + + if (!fullName) errors.push('Full Name is required'); + + let dateOfBirth = ''; + let ageYears: number | null = null; + if (!dobRaw) { + errors.push('Date of Birth is required'); + } else { + const parsed = new Date(dobRaw); + if (isNaN(parsed.getTime())) { + errors.push(`Date of Birth "${dobRaw}" is not a valid date (use YYYY-MM-DD)`); + } else if (parsed.getTime() > Date.now()) { + errors.push('Date of Birth cannot be in the future'); + } else { + dateOfBirth = parsed.toISOString().split('T')[0]; + ageYears = (Date.now() - parsed.getTime()) / (365.25 * 24 * 60 * 60 * 1000); + } + } + + let passengerType: 'Adult' | 'Child' | '' = ''; + const normalizedType = typeRaw.trim().toLowerCase(); + if (normalizedType === 'adult') passengerType = 'Adult'; + else if (normalizedType === 'child') passengerType = 'Child'; + else errors.push(`Passenger Type "${typeRaw}" must be "Adult" or "Child"`); + + // The backend computes ADULT/CHILD from date of birth alone (under 5 = Child), regardless + // of this column — flag a mismatch so the uploader notices before it surprises them later. + if (passengerType && ageYears !== null) { + const impliedType = ageYears < 5 ? 'Child' : 'Adult'; + if (impliedType !== passengerType) { + warnings.push(`Date of Birth implies ${impliedType}, but Passenger Type is set to ${passengerType} — seats/fare are priced by age, not this column`); + } + } + + if (!docTypeRaw) { + errors.push('ID Document Type is required'); + } else if (!VALID_ID_TYPES.includes(docTypeRaw)) { + errors.push(`ID Document Type "${docTypeRaw}" must be one of NATIONAL_ID, PASSPORT, DRIVING_LICENSE, OTHER`); + } + + if (docTypeRaw === 'PASSPORT' && !passportNumber) { + warnings.push('Passport Number is empty for a PASSPORT document type'); + } + + // The whole group is priced at one uniform fare tier (the one quoted at search time) — a + // row whose document type/nationality would actually resolve to the other tier will be + // priced wrong (over- or under-charged) with no per-passenger fare split to fix it. + if (quotedFareTier && VALID_ID_TYPES.includes(docTypeRaw)) { + const rowTier = inferredFareTier(docTypeRaw, nationality, passportCountry); + if (rowTier !== quotedFareTier) { + warnings.push( + `This passenger's documents imply ${rowTier === 'LOCAL' ? 'Local (Ethiopian/Djiboutian)' : 'International'} pricing, but the group was quoted at ${quotedFareTier === 'LOCAL' ? 'Local' : 'International'} rates — this passenger's actual fare will differ from the group rate`, + ); + } + } + + rows.push({ + rowNumber: r, + fullName, + dateOfBirth, + passengerType, + idDocumentType: docTypeRaw, + idDocumentNumber: docNumber, + passportNumber, + passportCountry, + nationality, + phone, + email, + errors, + warnings, + }); + } + + if (rows.length === 0) { + return { rows: [], fileErrors: ['No passenger rows found below the header. Fill in at least one row and try again.'] }; + } + + return { rows, fileErrors: [] }; +} + +export function countByType(rows: ParsedPassengerRow[]): { adults: number; children: number } { + return { + adults: rows.filter((r) => r.passengerType === 'Adult').length, + children: rows.filter((r) => r.passengerType === 'Child').length, + }; +}