From d5a5085d6d22f14245c0b767c705bf9b9a44e453 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 24 Aug 2026 23:49:20 +0000 Subject: [PATCH] feat: enhance booking and audit log functionalities - Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities. --- .../3690000000000-AuditLogReference.ts | 45 +++++ .../src/modules/audit/audit-endpoints.ts | 42 +++-- .../src/modules/audit/audit-log.repository.ts | 114 +++++++++-- .../modules/audit/audit-reference.registry.ts | 39 ++++ .../src/modules/audit/audit.controller.ts | 9 + .../src/modules/audit/audit.service.ts | 42 +++++ .../modules/audit/dto/audit-log-query.dto.ts | 38 ++++ .../audit/entities/audit-log.entity.ts | 14 ++ ...booking-wagon-cancellation.service.spec.ts | 67 +++++++ .../booking-wagon-cancellation.service.ts | 178 +++++++++++++++++- .../modules/bookings/bookings.controller.ts | 24 +++ .../modules/bookings/bookings.repository.ts | 52 +++++ .../bookings/dto/wagon-cancellation.dto.ts | 9 + .../contract-booking.completion.spec.ts | 82 ++++++++ .../contracts/contract-booking.service.ts | 77 ++++++++ .../train-scheduling.controller.ts | 3 +- .../dto/record-checkpoint.dto.ts | 18 ++ .../services/train-scheduling.service.ts | 91 ++++++++- .../contracts/GlCreateBookingForm.tsx | 84 ++++++++- .../ConsolidationPartnerPanel.tsx | 12 ++ .../trainScheduling/LogPassYardWorkModal.tsx | 95 +++++++--- .../backoffice/src/pages/AuditLogsPage.tsx | 87 ++++++++- .../pages/bookings/WagonCancellationsPage.tsx | 85 ++++++++- .../TrainScheduleV2DetailPage.tsx | 74 +++++++- .../src/services/auditLogs.service.ts | 20 ++ .../backoffice/src/types/trainScheduling.ts | 7 + .../components/WagonCancellationCard.tsx | 15 +- .../src/pages/bookings/RebookWagonsButton.tsx | 19 ++ 28 files changed, 1356 insertions(+), 86 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3690000000000-AuditLogReference.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit-reference.registry.ts 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 1495185f0..f190a60b9 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'; @@ -67,4 +69,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 5ac6ed0bb..0fd0304b3 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 @@ -2360,16 +2360,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.', @@ -2437,6 +2443,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; } @@ -2466,8 +2487,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, @@ -2832,14 +2855,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'); @@ -3061,6 +3105,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); @@ -9798,6 +9870,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 && ( +