diff --git a/INV-20260812-00005-mor.pdf b/INV-20260812-00005-mor.pdf new file mode 100644 index 000000000..4ce89c534 Binary files /dev/null and b/INV-20260812-00005-mor.pdf differ diff --git a/INV-20260812-00005-thermal.pdf b/INV-20260812-00005-thermal.pdf new file mode 100644 index 000000000..21df74ca7 Binary files /dev/null and b/INV-20260812-00005-thermal.pdf differ diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 79e350cc7..f36b36382 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -32,6 +32,7 @@ "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", + "seed:mor-test-buyers": "ts-node -r tsconfig-paths/register src/scripts/seed-mor-test-buyers.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 397845cb3..84e3e4b7a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { TruckTypesModule } from "./modules/truck-types/truck-types.module"; import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module"; +import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; @@ -95,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module"; import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; import { EimsModule } from "./modules/eims/eims.module"; import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; +import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; import { CargoesModule } from "./modules/cargoes/cargoes.module"; @@ -115,6 +117,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module"; import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; @@ -205,6 +208,7 @@ if (!process.env.APPLICATION_NAME) { LocomotivesModule, TruckTypesModule, TransitAgentsModule, + TransitAssignmentsModule, WagonTypesModule, TrainSetsModule, TrainSchedulesModule, @@ -256,11 +260,13 @@ if (!process.env.APPLICATION_NAME) { FirstMileModule, LastMileModule, LastMileRequestsModule, + EmptyReturnRequestsModule, InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, EimsModule, FleetHistoryModule, + WagonHistoryModule, AiModule, AuditModule, ChatModule, diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts new file mode 100644 index 000000000..817e967f8 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts @@ -0,0 +1,148 @@ +import { + collectAllPositions, + resolveActiveEmployee, + type SnapshotEmployee, +} from './freight-jwt.guard'; + +// Shapes and ids taken from the real dev session for `test_dj_gl_director` +// (iam.sessions 800ad793-…), an employee holding two posts on one row. +const CHIEF = { + id: '990189f1-e872-4b8c-9f6a-36259a0df480', + employeePositionId: 'd0d527f6-f344-49aa-ab8b-25a448a770b6', + name: { en: 'Djibouti GL Chief' }, + isDelegate: false, +}; +const DIRECTOR = { + id: '258a8d82-28c4-401f-bf88-78f58bb6bd0e', + employeePositionId: 'b97aa265-5de8-4ffe-95bf-01f94d38a2df', + name: { en: 'Djibouti GL Director' }, + isDelegate: false, +}; + +const EMPLOYEE_ID = '70545ee5-c7d7-4196-af7e-a7eb7e76b21b'; +const oneRow: SnapshotEmployee[] = [ + { id: EMPLOYEE_ID, positions: [CHIEF, DIRECTOR] }, +]; + +describe('resolveActiveEmployee', () => { + it('leaves the parent guard alone when no position header is sent', () => { + const { owner, active } = resolveActiveEmployee( + oneRow, + undefined, + EMPLOYEE_ID, + ); + + expect(owner).toBe(oneRow[0]); + expect(active).toBeUndefined(); + }); + + it("resolves freight's header value (employeePositionId)", () => { + const { active } = resolveActiveEmployee( + oneRow, + DIRECTOR.employeePositionId, + EMPLOYEE_ID, + ); + + expect(active).toBe(DIRECTOR); + }); + + // The regression this guard exists for: the stock IAM guard matches the + // header against employeePositionId only, so Smart Office's position.id + // matched nothing and every request silently ran as positions[0]. + it("resolves Smart Office's header value (position.id)", () => { + const { active } = resolveActiveEmployee(oneRow, DIRECTOR.id, EMPLOYEE_ID); + + expect(active).toBe(DIRECTOR); + expect(active).not.toBe(CHIEF); + }); + + it('falls back to the parent row when the header names nothing', () => { + const { owner, active } = resolveActiveEmployee( + oneRow, + 'not-a-position-id', + EMPLOYEE_ID, + ); + + expect(owner).toBe(oneRow[0]); + expect(active).toBeUndefined(); + }); + + describe('when the two posts sit on different employee rows', () => { + const smartOfficeRow: SnapshotEmployee = { + id: 'emp-smart-office', + positions: [CHIEF], + }; + const freightRow: SnapshotEmployee = { + id: 'emp-freight', + positions: [DIRECTOR], + }; + const twoRows = [smartOfficeRow, freightRow]; + + it('selects the row that owns the requested position', () => { + const { owner, active } = resolveActiveEmployee( + twoRows, + DIRECTOR.employeePositionId, + // The parent guard matches the header against position.id only, so it + // matched neither row and fell through to the first. + smartOfficeRow.id, + ); + + expect(owner).toBe(freightRow); + expect(active).toBe(DIRECTOR); + }); + + it('keeps the parent row when no header is sent', () => { + const { owner } = resolveActiveEmployee(twoRows, undefined, freightRow.id); + + expect(owner).toBe(freightRow); + }); + + it('falls back to the first row when the parent row is unknown', () => { + const { owner } = resolveActiveEmployee(twoRows, undefined, undefined); + + expect(owner).toBe(smartOfficeRow); + }); + }); +}); + +describe('collectAllPositions', () => { + it('unions posts held across separate employee rows', () => { + // The real shape: IAM keeps one employee row per organization, and "EDR" + // and "EDR Freight" are separate orgs, so a user holding a Smart Office + // post and a freight post owns one row each. + const smartOfficeRow: SnapshotEmployee = { + id: 'emp-edr', + organizationId: 'org-edr', + positions: [CHIEF], + }; + const freightRow: SnapshotEmployee = { + id: 'emp-edr-freight', + organizationId: 'org-edr-freight', + positions: [DIRECTOR], + }; + + expect(collectAllPositions([smartOfficeRow, freightRow])).toEqual([ + CHIEF, + DIRECTOR, + ]); + }); + + it('keeps every post when they share one row', () => { + expect(collectAllPositions(oneRow)).toEqual([CHIEF, DIRECTOR]); + }); + + it('de-duplicates a post repeated across rows', () => { + const rows: SnapshotEmployee[] = [ + { id: 'a', positions: [CHIEF] }, + { id: 'b', positions: [CHIEF, DIRECTOR] }, + ]; + + expect(collectAllPositions(rows)).toEqual([CHIEF, DIRECTOR]); + }); + + it('tolerates rows carrying no positions', () => { + const rows: SnapshotEmployee[] = [{ id: 'a' }, { id: 'b', positions: [] }]; + + expect(collectAllPositions(rows)).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-jwt.guard.ts b/apps/edr-freight-api/src/common/freight-jwt.guard.ts index 68b842217..9dbc36b73 100644 --- a/apps/edr-freight-api/src/common/freight-jwt.guard.ts +++ b/apps/edr-freight-api/src/common/freight-jwt.guard.ts @@ -3,29 +3,124 @@ import { Reflector } from '@nestjs/core'; import { InjectDataSource } from '@nestjs/typeorm'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { CURRENT_POSITION_ID } from '@tria-plc/api-common/utils/constants/tenant.constant'; import { DataSource } from 'typeorm'; /** One position as the login snapshot stores it (`iam.sessions.userInfo`). */ -type SnapshotPosition = { id?: string; [key: string]: unknown }; +export type SnapshotPosition = { + id?: string; + employeePositionId?: string; + isDelegate?: boolean; + [key: string]: unknown; +}; -type SessionUserInfo = { - employee?: { id?: string; positions?: SnapshotPosition[] }[]; +/** One employee row as the snapshot stores it. A user may hold several. */ +export type SnapshotEmployee = { + id?: string; + positions?: SnapshotPosition[]; + [key: string]: unknown; +}; + +type SessionUserInfo = { employee?: SnapshotEmployee[] }; + +/** + * `x-current-position-id` is sent with two different meanings by two different + * frontends, and the IAM guard reads it both ways in the same function: it + * picks the EMPLOYEE row by `position.id` but the POSITION by + * `employeePositionId`. Freight sends `employeePositionId`, Smart Office sends + * `position.id` — so whichever value arrives, one of the two lookups silently + * matches nothing and falls back to the first entry. + * + * Matching both fields is what makes the header mean one thing again. + */ +const identifies = (position: SnapshotPosition, id: string): boolean => + position?.id === id || position?.employeePositionId === id; + +/** + * Every post the user holds, across every employee row, first occurrence kept. + * + * IAM keeps one employee row per ORGANIZATION, and "EDR" and "EDR Freight" are + * separate organizations — so a user given a freight post and a Smart Office + * post owns two rows, one post on each. Only one row can be the active one, and + * a permission check that reads only that row cannot see the other post at all. + */ +export const collectAllPositions = ( + employees: SnapshotEmployee[], +): SnapshotPosition[] => { + const seen = new Set(); + const all: SnapshotPosition[] = []; + + for (const employee of employees) { + for (const position of employee.positions ?? []) { + const key = position.employeePositionId ?? position.id; + if (key) { + if (seen.has(key)) continue; + seen.add(key); + } + all.push(position); + } + } + + return all; }; /** - * Like the IAM JwtGuard, but keeps the caller's SECONDARY positions. + * Which employee row the caller is acting as, and which of its positions the + * request selected. Pure so it can be tested without a session or a token. * - * IAM models an employee as holding many positions, and the login snapshot in - * `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` then - * collapses that to a single `employee.position` — whichever the request - * headers select, else `positions[0]` — and drops the rest. Non-delegate - * secondary positions vanish entirely, so staff holding two posts resolve to - * only one post's permissions and every check on the other one rejects them. + * `owner` is the row holding the requested position; failing that the row the + * parent guard already picked; failing that the first. `active` is undefined + * when no header was sent or it names nothing — the caller then leaves the + * parent's choice of `employee.position` alone. + */ +export const resolveActiveEmployee = ( + employees: SnapshotEmployee[], + requestedId: string | undefined, + parentEmployeeId: string | undefined, +): { owner: SnapshotEmployee | undefined; active: SnapshotPosition | undefined } => { + const owner = + (requestedId && + employees.find((candidate) => + (candidate.positions ?? []).some((position) => + identifies(position, requestedId), + ), + )) || + employees.find( + (candidate) => candidate.id && candidate.id === parentEmployeeId, + ) || + employees[0]; + + const active = requestedId + ? (owner?.positions ?? []).find((position) => + identifies(position, requestedId), + ) + : undefined; + + return { owner, active }; +}; + +/** + * Like the IAM JwtGuard, but resolves the caller's position honestly. * - * This re-attaches the full list as `employee.positions`. `employee.position` - * is left exactly as the parent set it, so everything reading the single - * position today (audit log, delegation deadline) is unaffected; only the - * permission utils, which prefer the array, see the difference. + * IAM models an employee as holding many positions — and a user as possibly + * holding several employee rows — and the login snapshot in + * `iam.sessions.userInfo` carries all of them. `JwtGuard.parseToken` collapses + * that to a single `employee.position` and drops the rest, so staff holding two + * posts resolve to one post's permissions and every check on the other one + * rejects them. + * + * This guard re-reads the snapshot and fixes three things the parent gets wrong: + * + * 1. re-attaches the full position list as `employee.positions`, which is what + * the permission utils union over; + * 2. selects the employee row that actually owns the requested position, so a + * post held on a second employee row is reachable at all; + * 3. sets `employee.position` to the requested position when the parent's + * one-sided id match missed it, keeping `auditUser` in step. + * + * Every correction is skipped unless the snapshot positively resolves it, so an + * unreadable session degrades to the parent's single-position behaviour rather + * than to no position at all. */ @Injectable() export class FreightJwtGuard extends IamJwtGuard implements CanActivate { @@ -35,7 +130,7 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate { private static readonly CACHE_MAX_ENTRIES = 5_000; private readonly cache = new Map< string, - { positions: SnapshotPosition[]; expiresAt: number } + { employees: SnapshotEmployee[]; expiresAt: number } >(); constructor( @@ -48,44 +143,78 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { if (!(await super.canActivate(context))) return false; - const user = context.switchToHttp().getRequest().user as - | TCurrentUser - | undefined; - const employee = user?.employee; + const request = context.switchToHttp().getRequest(); + const user = request.user as TCurrentUser | undefined; + const employee = user?.employee as SnapshotEmployee | undefined; if (!employee || !user?.sessionId) return true; - const positions = await this.positionsForSession( - user.sessionId, + const employees = await this.employeesForSession(user.sessionId); + if (!employees.length) return true; + + const requestedId = request.headers?.[CURRENT_POSITION_ID] as + | string + | undefined; + + const { owner, active } = resolveActiveEmployee( + employees, + requestedId, employee.id, ); - // Never blank out what the parent resolved: an unreadable session or a - // snapshot without positions must degrade to the single-position - // behaviour, not to no positions at all. - if (positions.length) { - (employee as { positions?: SnapshotPosition[] }).positions = positions; + + const ownerPositions = owner?.positions ?? []; + // Never blank out what the parent resolved: a snapshot without positions + // must degrade to the single-position behaviour, not to no positions. + if (!ownerPositions.length) return true; + + // Carries the owning row's id / unitId / organizationId too, which unit + // scoping downstream reads — a swapped row must be swapped whole. + Object.assign(employee, owner); + + // `collectPermissionKeys` / `collectPositionTypeKeys` union over this, and + // a user's posts can span several employee rows (one per organization), so + // it carries every row's — otherwise a freight post is invisible whenever + // another organization's row wins the active slot. + employee.positions = collectAllPositions(employees); + + // Delegation stays scoped to the active desk: yard scope widens on + // `delegatedPositions`, and someone standing in on another organization's + // row is not this desk's stand-in. + employee.delegatedPositions = ownerPositions.filter( + (position) => position.isDelegate, + ); + + // The full set, for `/auth/me` — the position picker has to be able to + // offer a desk on a row that is not the active one. + (user as { employeeRows?: SnapshotEmployee[] }).employeeRows = employees; + + if (active) { + employee.position = active; + // The parent already built `auditUser` from the position it guessed. + if (request.auditUser) { + request.auditUser.employeeId = employee.id; + request.auditUser.positionId = active.id; + request.auditUser.employeePositionId = active.employeePositionId; + } } + return true; } - /** Every position the login snapshot holds for this employee. */ - private async positionsForSession( + /** Every employee row the login snapshot holds for this session. */ + private async employeesForSession( sessionId: string, - employeeId: string | undefined, - ): Promise { + ): Promise { const now = Date.now(); const hit = this.cache.get(sessionId); - if (hit && hit.expiresAt > now) return hit.positions; + if (hit && hit.expiresAt > now) return hit.employees; - let positions: SnapshotPosition[] = []; + let employees: SnapshotEmployee[] = []; try { const rows: { userInfo: SessionUserInfo | null }[] = await this.ds.query( `SELECT "userInfo" FROM iam.sessions WHERE id = $1`, [sessionId], ); - const employees = rows[0]?.userInfo?.employee ?? []; - const match = - employees.find((e) => e?.id && e.id === employeeId) ?? employees[0]; - positions = match?.positions ?? []; + employees = rows[0]?.userInfo?.employee ?? []; } catch { return []; // iam unreachable — caller keeps the parent's single position } @@ -93,9 +222,9 @@ export class FreightJwtGuard extends IamJwtGuard implements CanActivate { if (this.cache.size >= FreightJwtGuard.CACHE_MAX_ENTRIES) this.cache.clear(); this.cache.set(sessionId, { - positions, + employees, expiresAt: now + FreightJwtGuard.CACHE_TTL_MS, }); - return positions; + return employees; } } diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts index 8c3310321..c060130cd 100644 --- a/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts +++ b/apps/edr-freight-api/src/common/mile-haulage.util.spec.ts @@ -1,4 +1,4 @@ -import { usesEdrMileService } from './mile-haulage.util'; +import { edrHaulsThisBooking, usesEdrMileService } from './mile-haulage.util'; /** * The road legs are chosen on the contract and copied onto the booking. EDR @@ -26,27 +26,76 @@ describe('usesEdrMileService', () => { }); it('an export that chose collection uses EDR haulage', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })), - ).toBe(true); + expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe( + true, + ); }); it('ignores the delivery address on an export — delivery is the import leg', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })), - ).toBe(false); + expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' }))).toBe( + false, + ); }); it('a domestic booking counts either leg', () => { - expect( - usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })), - ).toBe(true); - expect( - usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), - ).toBe(true); + expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe( + true, + ); + expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' }))).toBe( + true, + ); }); it('treats a whitespace-only address as no choice', () => { expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false); }); }); + +/** + * Self-haul is closed only once EDR has committed to the leg. Delivery chosen + * on the contract is a request the chief still has to approve; collection has + * no approval step. + */ +describe('edrHaulsThisBooking', () => { + const booking = (over: Partial[0]> = {}) => ({ + tradeDirection: 'IMPORT', + firstMile: null, + lastMile: null, + lastMileCommitted: false, + ...over, + }); + + it('an import whose last-mile request is not yet approved may still self-haul', () => { + expect(edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(false); + }); + + it('an import whose last-mile request was approved is hauled by EDR', () => { + expect( + edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa', lastMileCommitted: true })), + ).toBe(true); + }); + + it('an import that chose no delivery self-hauls, whatever the leg tables say', () => { + expect(edrHaulsThisBooking(booking({ lastMileCommitted: true }))).toBe(false); + }); + + it('an export that chose collection is hauled by EDR — no approval step on that leg', () => { + expect(edrHaulsThisBooking(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe( + true, + ); + }); + + it('a domestic booking is blocked by collection, or by an approved delivery', () => { + expect(edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe( + true, + ); + expect( + edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })), + ).toBe(false); + expect( + edrHaulsThisBooking( + booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa', lastMileCommitted: true }), + ), + ).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/common/mile-haulage.util.ts b/apps/edr-freight-api/src/common/mile-haulage.util.ts index 1ca83d06e..c9382d45c 100644 --- a/apps/edr-freight-api/src/common/mile-haulage.util.ts +++ b/apps/edr-freight-api/src/common/mile-haulage.util.ts @@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE = 'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.'; export const EDR_HAULAGE_CONFLICT_MESSAGE = - 'Customer truck assignment is only allowed when first/last mile delivery is not selected'; + 'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved'; + +/** The booking fields that decide whether the customer may still bring their own truck. */ +export interface MileCommitmentRow extends MileHaulageRow { + /** + * EDR has actually committed to the delivery leg: the booking's last-mile + * request was approved, or a `freight.last_mile` leg row exists for it. + * Selecting delivery on the contract is only a request — see + * `edrHaulsThisBooking`. + */ + lastMileCommitted: boolean; +} + +/** + * SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the + * booking row aliased `b`. Both services that gate self-haul read the same + * fragment so the rule cannot drift between them. + */ +export const LAST_MILE_COMMITTED_SQL = `( + EXISTS (SELECT 1 + FROM freight.last_mile lm + WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL) + OR EXISTS (SELECT 1 + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = b.id + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED') +)`; + +/** + * Whether EDR is hauling this booking's road leg, such that the customer may + * NOT assign their own truck. Stricter than `usesEdrMileService` on the + * delivery side: choosing last-mile delivery on the contract opens a request + * that the Truck & Machinery chief still has to approve, and until that + * approval the customer is free to self-haul instead. Collection (the export + * leg) has no approval step, so the contract choice alone decides it. + * + * `usesEdrMileService` keeps answering the other question — whether the booking + * belongs in the EDR mile queues at all — and the queue side still refuses a + * booking that already carries a customer truck, so the two paths remain + * mutually exclusive whichever acts first. + */ +export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean { + const hasFirstMile = Boolean(booking.firstMile?.trim()); + const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted; + switch (booking.tradeDirection) { + case 'IMPORT': + return lastMileApproved; + case 'EXPORT': + return hasFirstMile; + default: + return hasFirstMile || lastMileApproved; + } +} /** * The road legs are chosen on the contract. A booking whose contract bought diff --git a/apps/edr-freight-api/src/common/truck-load.util.spec.ts b/apps/edr-freight-api/src/common/truck-load.util.spec.ts index fbb3a436a..de5ec9550 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.spec.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.spec.ts @@ -45,6 +45,16 @@ describe('assertTruckLoad', () => { ).toThrow(BadRequestException); }); + it('allows two containers only when both are explicitly 20ft', () => { + expect(() => + assertTruckLoad({ + containers: ['ABCD1234567', 'ABCD7654321'], + bookingContainers: booking, + sizes: ['20ft', '45ft'], + }), + ).toThrow(BadRequestException); + }); + it('rejects more than two containers', () => { expect(() => assertTruckLoad({ diff --git a/apps/edr-freight-api/src/common/truck-load.util.ts b/apps/edr-freight-api/src/common/truck-load.util.ts index b65bc12fb..6380786bd 100644 --- a/apps/edr-freight-api/src/common/truck-load.util.ts +++ b/apps/edr-freight-api/src/common/truck-load.util.ts @@ -54,10 +54,11 @@ export function assertTruckLoad({ } } - // A 40ft fills the bed, so it travels alone. - if (containers.length > 1 && sizes.some((size) => size.includes('40'))) { + // A truck may pair containers only when BOTH are explicitly 20ft. A 40ft + // (and any legacy/unknown larger size) fills the bed and travels alone. + if (containers.length > 1 && sizes.some((size) => !size.includes('20'))) { throw new BadRequestException( - 'A 40ft container fills the truck — assign only 1 container to this truck', + 'Truck capacity is either 1 x 40ft container or up to 2 x 20ft containers', ); } } diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts index 4f060ca11..c4588af07 100644 --- a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -4,25 +4,29 @@ import { ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, -} from 'class-validator'; -import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js'; +} from "class-validator"; +import { + isValidPhoneNumber, + parsePhoneNumberFromString, +} from "libphonenumber-js"; /** * Country-aware phone validation. The value is expected as a full international - * number (E.164, e.g. "+251911223344"), so the country is derived from the - * value itself — no separate country field needed. + * number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for + * Ethiopia), so the country is derived from the value itself — no separate + * country field needed. */ -@ValidatorConstraint({ name: 'IsValidPhone', async: false }) +@ValidatorConstraint({ name: "IsValidPhone", async: false }) export class IsValidPhoneConstraint implements ValidatorConstraintInterface { validate(value: unknown): boolean { // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. - if (value === undefined || value === null || value === '') return true; - if (typeof value !== 'string') return false; + if (value === undefined || value === null || value === "") return true; + if (typeof value !== "string") return false; return isValidPhoneNumber(value); } defaultMessage(args: ValidationArguments): string { - return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`; + return `${args.property} must be a complete international phone number (E.164, e.g. +25377834567 or +251911223344)`; } } @@ -53,7 +57,7 @@ export function IsValidPhone(validationOptions?: ValidationOptions) { export function normalizeE164( value: string | null | undefined, ): string | null | undefined { - if (value === undefined || value === null || value === '') return value; - const parsed = parsePhoneNumberFromString(value, 'ET'); + if (value === undefined || value === null || value === "") return value; + const parsed = parsePhoneNumberFromString(value, "ET"); return parsed?.isValid() ? parsed.number : value.trim(); } diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts index a6aa3895d..5e89822f4 100644 --- a/apps/edr-freight-api/src/config/eims.config.spec.ts +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -26,6 +26,20 @@ const withEnv = (vars: Record, fn: () => void) => { }; describe("eims.config — private key / certificate resolution", () => { + it("requires EIMS_API_KEY when EIMS is enabled without exposing a value", () => { + withEnv( + { + ...REQUIRED, + EIMS_API_KEY: undefined, + EIMS_PRIVATE_KEY: "private-key-present", + EIMS_CERTIFICATE: "certificate-present", + }, + () => { + expect(() => eimsConfigFactory()).toThrow(/env vars are missing: EIMS_API_KEY/); + }, + ); + }); + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { withEnv( { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts index 0fc2607e2..77d230583 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.spec.ts @@ -29,6 +29,8 @@ const FIXTURE: MorLocationTuple[] = [ [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 976, "Wal-Mera"], [70, "Ethiopia", 2, "OROMIA", 86, "FINFINE VIC SPEC", 909, "Akaki woreda"], [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1100, "WOREDA 1"], + [70, "Ethiopia", 13, "ADDIS ABABA", 78, "BOLE", 1102, "WOREDA 3"], + [70, "Ethiopia", 13, "ADDIS ABABA", 81, "KOLFIE KERANIYO", 1139, "WOREDA 7"], [253, "Djibouti", 1, "DJIBOUTI", 1, "DJIBOUTI VILLE", 1, "BALBALA"], ]; @@ -181,6 +183,93 @@ describe("resolveMorGeo", () => { }); }); + describe("Addis Ababa, where MoR has no zone tier", () => { + // e-Trade's real shape for a chartered city: `zone` repeats the region, the sub-city sits in + // `woreda`, and the numbered woreda sits in `kebele`. This is how every company imported from + // e-Trade stores an Addis Ababa address, and it is the shape that blocked INV-20260829-00011. + const ETRADE_SHAPE = { + country: "Ethiopia", + region: "Addis Ababa", + zone: "Addis Ababa", + woreda: "Kolfe-Keraniyo", + kebele: "07", + }; + + it("reads the sub-city and woreda one level down when the zone repeats the region", () => { + expect(resolveMorGeo(ETRADE_SHAPE, FIXTURE)).toEqual({ + Country: "70", + Region: "13", + City: "81", + Wereda: "1139", + }); + }); + + it("matches MoR's own 'KOLFIE KERANIYO' spelling of the sub-city", () => { + expect(resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Kolfe Keranio" }, FIXTURE).City).toBe("81"); + }); + + it("reads a zero-padded number as MoR's 'WOREDA n' locality, in either slot", () => { + const bole = { country: "Ethiopia", region: "Addis Ababa", zone: "Bole" }; + expect(resolveMorGeo({ ...bole, woreda: "03" }, FIXTURE).Wereda).toBe("1102"); + expect(resolveMorGeo({ ...bole, woreda: "Woreda 03" }, FIXTURE).Wereda).toBe("1102"); + expect(resolveMorGeo({ ...bole, woreda: "WOREDA 3" }, FIXTURE).Wereda).toBe("1102"); + }); + + it("still resolves the already-correct shape without shifting", () => { + expect( + resolveMorGeo( + { country: "Ethiopia", region: "ADDIS ABABA", zone: "BOLE", woreda: "WOREDA 1" }, + FIXTURE, + ), + ).toEqual({ Country: "70", Region: "13", City: "78", Wereda: "1100" }); + }); + + it("reports the zone failure, not the shifted one, when the shift does not resolve", () => { + // LEMI KURA is a 2020 sub-city the Ministry sheet does not list. The shift must not turn + // that into a confusing locality error, and must never land on a neighbouring sub-city. + expect(() => + resolveMorGeo({ ...ETRADE_SHAPE, woreda: "Lemi Kura", kebele: "02" }, FIXTURE), + ).toThrow(/no MoR CITY_NAME match for country="Ethiopia", region="Addis Ababa"/); + }); + + it("does not shift when the zone is simply an unknown zone", () => { + expect(() => + resolveMorGeo( + { + country: "Ethiopia", + region: "OROMIA", + zone: "East Zone", + woreda: "KERSA", + kebele: "01", + }, + FIXTURE, + ), + ).toThrow(/no MoR CITY_NAME match/); + }); + }); + + it("resolves the regions and zones MoR spells differently from e-Trade", () => { + // Guards the reviewed alias table: MoR's PARISH_NAME is "AMAHARA", and it keeps the Amharic + // compass words for the Oromia zones ("MISRAK SHOA" for East Shewa). + const rows: MorLocationTuple[] = [ + ...FIXTURE, + [70, "Ethiopia", 2, "OROMIA", 16, "MISRAK SHOA", 21, "ADAMA"], + [70, "Ethiopia", 11, "AMAHARA", 53, "WEST GOJAM", 149, "MECHA"], + ]; + expect( + resolveMorGeo( + { country: "Ethiopia", region: "Oromia", zone: "East Shewa", woreda: "Adama" }, + rows, + ), + ).toEqual({ Country: "70", Region: "2", City: "16", Wereda: "21" }); + expect( + resolveMorGeo( + { country: "Ethiopia", region: "Amhara", zone: "West Gojjam", woreda: "Mecha" }, + rows, + ), + ).toEqual({ Country: "70", Region: "11", City: "53", Wereda: "149" }); + }); + describe("failures happen locally, before anything is filed", () => { const cases: Array<[string, Record, RegExp]> = [ ["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/], diff --git a/apps/edr-freight-api/src/config/mor-location.resolver.ts b/apps/edr-freight-api/src/config/mor-location.resolver.ts index 72459db9b..0b0cef6e2 100644 --- a/apps/edr-freight-api/src/config/mor-location.resolver.ts +++ b/apps/edr-freight-api/src/config/mor-location.resolver.ts @@ -43,6 +43,11 @@ export interface MorAddressInput { region?: string | null; zone?: string | null; woreda?: string | null; + /** + * Only read for the city-region shift below — in Addis Ababa e-Trade stores the numbered woreda + * here. Never consulted for an ordinary region/zone/woreda address. + */ + kebele?: string | null; } type Level = "country" | "region" | "zone" | "woreda"; @@ -115,6 +120,24 @@ const ALIASES: MorAlias[] = [ from: "Jigjiga", to: "JIJIGA", }, + // MoR misspells the region itself — PARISH_NO 11 is "AMAHARA". No other parish is close to it. + { level: "region", from: "Amhara", to: "AMAHARA" }, + // Addis Ababa sub-cities, where MoR's sheet and e-Trade disagree on spelling. Each confirmed by + // CITY_NO under PARISH_NO 13; the seven that already agree (ARADA, ADDIS KETEMA, LIDETA, KIRKOS, + // YEKA, BOLE, GULLELE) need no entry. LEMI KURA is deliberately absent — the Ministry sheet does + // not list the 2020 split at all, so it must keep failing rather than be mapped onto a neighbour. + { level: "zone", region: "ADDIS ABABA", from: "Kolfe Keraniyo", to: "KOLFIE KERANIYO" }, // 81 + { level: "zone", region: "ADDIS ABABA", from: "Kolfe Keranio", to: "KOLFIE KERANIYO" }, // 81 + { level: "zone", region: "ADDIS ABABA", from: "Nifas Silk Lafto", to: "NEFAS SILK LAFTO" }, // 80 + { level: "zone", region: "ADDIS ABABA", from: "Akaki Kality", to: "AKAKI KALITI" }, // 79 + // MoR keeps the Amharic compass words for the Oromia zones; e-Trade stores the English ones. + // Each pair confirmed by the zone's own localities in the sheet: MISRAK SHOA holds ADAMA and + // BISHOFTU, MIRAB SHOA holds AMBO and WELMERA, MIRAB HARARGE holds CHIRO and GEMMECHIS. + { level: "zone", region: "OROMIA", from: "East Shewa", to: "MISRAK SHOA" }, // 16 + { level: "zone", region: "OROMIA", from: "West Shewa", to: "MIRAB SHOA" }, // 62 + { level: "zone", region: "OROMIA", from: "West Hararge", to: "MIRAB HARARGE" }, // 7 + // MoR drops a J. Confirmed by BAHIRDAR ZURIA / MECHA / BURIE sitting under CITY_NO 53. + { level: "zone", region: "AMAHARA", from: "West Gojjam", to: "WEST GOJAM" }, // 53 ]; /** @@ -127,6 +150,20 @@ const ALIASES: MorAlias[] = [ const zoneSuffixCandidates = (normalized: string): string[] => normalized.endsWith(" ZONE") ? [] : [`${normalized} ZONE`]; +/** + * In the chartered cities MoR names each locality "WOREDA 7", while e-Trade stores the bare, + * zero-padded number ("07") and EDR's own forms sometimes store "Woreda 05". All three mean the + * same locality, so the MoR spelling is tried as a second exact-match candidate — MoR writes no + * leading zero, hence the strip. Applied to the locality level only. + * + * This runs ahead of the numeric LOCALITY_NO fallback below, and can never mask it: no city in the + * Ministry sheet contains both a "WOREDA n" locality and a locality whose LOCALITY_NO is n. + */ +const woredaNumberCandidates = (normalized: string): string[] => { + const match = /^(?:WOREDA )?0*([0-9]{1,2})$/.exec(normalized); + return match ? [`WOREDA ${match[1]}`] : []; +}; + export class MorGeoMappingError extends BadRequestException { constructor(code: "EIMS_GEO_MAPPING_FAILED" | "EIMS_GEO_AMBIGUOUS", message: string) { super({ code, message }); @@ -158,6 +195,7 @@ function matchLevel( if (normalizeName(alias.from) === wanted) candidates.push(normalizeName(alias.to)); } if (level === "zone") candidates.push(...zoneSuffixCandidates(wanted)); + if (level === "woreda") candidates.push(...woredaNumberCandidates(wanted)); } let matched: MorLocationTuple[] = []; @@ -221,25 +259,46 @@ export function resolveMorGeo( const inCountry = matchLevel(rows, "country", country, {}, input); const inRegion = matchLevel(inCountry.rows, "region", input.region, {}, input); const regionScope = normalizeName(inRegion.rows[0][SLOTS.region.name] as string); - const inZone = matchLevel(inRegion.rows, "zone", input.zone, { region: regionScope }, input); - const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); - const inWoreda = matchLevel( - inZone.rows, - "woreda", - input.woreda, - { - region: regionScope, - zone: zoneScope, - }, - input, - ); - return { - Country: String(inCountry.no), - Region: String(inRegion.no), - City: String(inZone.no), - Wereda: String(inWoreda.no), + type Name = string | null | undefined; + const below = (zone: Name, woreda: Name): MorGeoCodes => { + const inZone = matchLevel(inRegion.rows, "zone", zone, { region: regionScope }, input); + const zoneScope = normalizeName(inZone.rows[0][SLOTS.zone.name] as string); + const inWoreda = matchLevel( + inZone.rows, + "woreda", + woreda, + { + region: regionScope, + zone: zoneScope, + }, + input, + ); + return { + Country: String(inCountry.no), + Region: String(inRegion.no), + City: String(inZone.no), + Wereda: String(inWoreda.no), + }; }; + + try { + return below(input.zone, input.woreda); + } catch (err) { + // Addis Ababa (and every other chartered city) has no zone tier: MoR's CITY level *is* the + // sub-city and its LOCALITY level is the numbered woreda. e-Trade fills the missing tier by + // repeating the region in `zone`, which pushes the sub-city into `woreda` and the woreda + // number into `kebele` — one level down the whole way. Retry with that reading, but only when + // `zone` genuinely repeats the region, and only accept it when *both* shifted levels resolve + // exactly. A zone MoR simply does not list still fails with its own message, unreinterpreted. + const zone = normalizeName(input.zone); + if (!zone || (zone !== regionScope && zone !== normalizeName(input.region))) throw err; + try { + return below(input.woreda, input.kebele); + } catch { + throw err; + } + } } /** Non-throwing variant for callers that already have a working fallback (the seller identity). */ diff --git a/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts b/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts new file mode 100644 index 000000000..eedf8b149 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3790000000000-TransitAgentAccount.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Give a transit agent a portal login. + * + * Every column is NULLABLE and nothing is backfilled: production already holds + * transit agents that exist only as a GL-assignable roster entry, and they must + * keep working untouched. An agent gains an account when staff invite it — at + * which point `user_id` is filled in — so "has a login" is exactly + * `user_id IS NOT NULL`, and the assignment flow never has to care. + * + * The unique indexes are partial (`WHERE ... IS NOT NULL`) because Postgres + * treats NULLs as distinct in a plain unique index only per-row; being explicit + * documents that many account-less agents are expected to coexist. + */ +export class TransitAgentAccount3790000000000 implements MigrationInterface { + name = "TransitAgentAccount3790000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.transit_agents + ADD COLUMN IF NOT EXISTS user_id uuid, + ADD COLUMN IF NOT EXISTS email varchar(150), + ADD COLUMN IF NOT EXISTS phone_number varchar(30)`, + ); + // One IAM account can back at most one transit agent — otherwise a single + // login would resolve to two agents in `findByUserId`. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_user_id + ON freight.transit_agents (user_id) + WHERE user_id IS NOT NULL AND deleted_at IS NULL`, + ); + // Case-insensitive, matching how the repository checks for duplicates. + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_email + ON freight.transit_agents (lower(email)) + WHERE email IS NOT NULL AND deleted_at IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.ux_transit_agents_email`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS freight.ux_transit_agents_user_id`, + ); + await queryRunner.query( + `ALTER TABLE freight.transit_agents + DROP COLUMN IF EXISTS phone_number, + DROP COLUMN IF EXISTS email, + DROP COLUMN IF EXISTS user_id`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts b/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts new file mode 100644 index 000000000..df994d832 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3800000000000-BookingCancellationWagons.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon footprint pinned for cancellation pricing. `wagons_required` is a LIVE + * scheduling field — unassign clears it to NULL — so a paid booking pulled off + * a train had nothing left to price a cancellation fee or credit against + * ("This booking has no wagon requirement to cancel from."). This column is + * stamped once, at first allocation, and never cleared: cancellation reads it + * (falling back to a computed count for bookings never allocated). + */ +export class BookingCancellationWagons3800000000000 implements MigrationInterface { + name = 'BookingCancellationWagons3800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS cancellation_wagons numeric(6,2) + `); + // Backfill the bookings that still carry a live stamp. + await queryRunner.query(` + UPDATE freight.bookings + SET cancellation_wagons = wagons_required + WHERE cancellation_wagons IS NULL + AND wagons_required IS NOT NULL + AND wagons_required > 0 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS cancellation_wagons + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts new file mode 100644 index 000000000..9d0116681 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3810000000000-TransitAssignments.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Transit assignments — one row per (booking × transit agent), so an agent + * handles many bookings. + * + * Deliberately NOT the existing transit-assignee handshake on bookings + * (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on + * the booking itself): that is a pre-declaration agreement between GL Ethiopia + * and GL Djibouti about WHO will handle customs. This is the work record — + * status, timings and documents — and nothing here reads or writes that flow. + * + * There is no duration column on purpose. The time taken after the train + * arrives is `finished_at − bookings.arrived_at`, and both halves already + * exist; storing the difference would be a third source of truth that goes + * stale the moment either timestamp is corrected. It is computed on read. + * + * Documents hang off `freight.files` with `resource = 'transit_assignments'` + * and `resource_id = transit_assignments.id`. That table already carries the + * MinIO object, the upload time (`created_at`), the uploader, the edit time + * (`updated_at`) and the supersede history, so no file table is added here. + */ +export class TransitAssignments3810000000000 implements MigrationInterface { + name = "TransitAssignments3810000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.transit_assignments ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL, + transit_agent_id uuid NOT NULL, + status varchar(32) NOT NULL DEFAULT 'NOT_STARTED', + started_at timestamptz, + finished_at timestamptz, + assigned_by_user_id uuid, + assigned_at timestamptz NOT NULL DEFAULT now(), + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_transit_assignments PRIMARY KEY (id), + CONSTRAINT fk_transit_assignments_booking + FOREIGN KEY (booking_id) REFERENCES freight.bookings (id), + CONSTRAINT fk_transit_assignments_agent + FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id) + ) + `); + + // One live assignment per (booking, agent). Partial so a soft-deleted row + // never blocks re-assigning the same agent to the same booking later. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent + ON freight.transit_assignments (booking_id, transit_agent_id) + WHERE deleted_at IS NULL + `); + + // The two list directions: a booking's assignments, and an agent's workload. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking + ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status + ON freight.transit_assignments (transit_agent_id, status) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts b/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts new file mode 100644 index 000000000..014df7491 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3820000000000-WagonEvents.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Unified per-wagon history ledger. One append-only row per transition + * (yard move, coupling, schedule pin/dispatch/release, status flip, cargo + * load/unload, container placement, lifecycle edits), written in the same + * transaction as the change. No foreign keys: history must survive the wagon, + * train, schedule or booking it points at. The two composite indexes back + * keyset pagination of a single wagon's timeline (optionally per category); + * the partial ones answer "what happened on this schedule / booking". + */ +export class WagonEvents3820000000000 implements MigrationInterface { + name = 'WagonEvents3820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + wagon_id uuid NOT NULL, + wagon_number varchar, + event_type varchar(40) NOT NULL, + category varchar(20) NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + actor_user_id uuid, + from_yard_id uuid, + to_yard_id uuid, + train_id uuid, + train_schedule_id uuid, + booking_id uuid, + from_value varchar(120), + to_value varchar(120), + reason text, + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now() + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_time + ON freight.wagon_events (wagon_id, occurred_at DESC, id DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_wagon_cat_time + ON freight.wagon_events (wagon_id, category, occurred_at DESC, id DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_schedule + ON freight.wagon_events (train_schedule_id) + WHERE train_schedule_id IS NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_events_booking + ON freight.wagon_events (booking_id) + WHERE booking_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_events`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts new file mode 100644 index 000000000..157b95570 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customer-initiated empty container return, for a booking that did NOT buy + * the return service up front. The customer names the containers coming back, + * operations approves and prices it off the contract's WITH_RETURN rate, the + * customer pays that invoice and then books the date and truck. The empty + * itself is still recorded through `empty_container_returns` when the truck + * actually arrives — this table only carries the request up to that point. + */ +export class EmptyReturnRequests3840000000000 implements MigrationInterface { + name = 'EmptyReturnRequests3840000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.empty_return_requests ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + booking_id uuid NOT NULL, + company_id uuid, + status varchar(30) NOT NULL DEFAULT 'SUBMITTED', + container_numbers text[] NOT NULL DEFAULT '{}', + container_count smallint NOT NULL DEFAULT 0, + quoted_unit_amount numeric(14,2), + quoted_total_amount numeric(14,2), + currency varchar(8), + invoice_id uuid, + paid_at timestamptz, + requested_return_date date, + truck_plate_number varchar(32), + truck_driver_name varchar(120), + truck_type varchar(60), + scheduled_at timestamptz, + submitted_by_user_id uuid, + submitted_at timestamptz NOT NULL DEFAULT now(), + reviewed_by_staff_id uuid, + reviewed_at timestamptz, + rejection_reason text, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_booking + ON freight.empty_return_requests (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_status + ON freight.empty_return_requests (status) + `); + + // A container number may only be owed back once at a time. That guard is + // per array element, so it lives in the service (see assertContainersFree) + // rather than in a unique index — this GIN index is what makes the check + // cheap. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_containers + ON freight.empty_return_requests USING gin (container_numbers) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts index cf12fd4d3..8b5c82e9c 100644 --- a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts +++ b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts @@ -4,24 +4,17 @@ import { HttpException, Injectable, NestInterceptor, -} from '@nestjs/common'; -import { Observable, tap } from 'rxjs'; -import type { Request, Response } from 'express'; +} from "@nestjs/common"; +import { Observable, tap } from "rxjs"; +import type { Request, Response } from "express"; -import { AuditService } from './audit.service'; -import { - auditEndpointMatcher, - type MatchedAuditEndpoint, -} from './audit-endpoint-matcher'; -import { - isAuditableActor, - resolveAuditActor, - type AuditActorSource, -} from './audit-actor'; -import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer'; +import { AuditService } from "./audit.service"; +import { auditEndpointMatcher, type MatchedAuditEndpoint } from "./audit-endpoint-matcher"; +import { isAuditableActor, resolveAuditActor, type AuditActorSource } from "./audit-actor"; +import { redactUrlQuery, sanitizeRequestPayload } from "./audit.sanitizer"; /** Methods that can change state. Everything else is never audited. */ -const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const AUDITED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** `error_message` ceiling — stack traces do not belong in this column. */ const MAX_ERROR_LENGTH = 2_000; @@ -52,7 +45,7 @@ export class AuditInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { // Non-HTTP contexts (the RabbitMQ microservice transport) have no request. - if (context.getType() !== 'http') return next.handle(); + if (context.getType() !== "http") return next.handle(); const httpContext = context.switchToHttp(); const request = httpContext.getRequest(); @@ -72,10 +65,7 @@ export class AuditInterceptor implements NestInterceptor { const startedAt = Date.now(); // The body is captured up front: handlers are free to mutate the DTO they // are given, so reading it after the fact can record post-mutation values. - const requestPayload = sanitizeRequestPayload( - request.body, - request.files ?? request.file, - ); + const requestPayload = sanitizeRequestPayload(request.body, request.files ?? request.file); return next.handle().pipe( tap({ @@ -137,7 +127,6 @@ export class AuditInterceptor implements NestInterceptor { resourceId: matched.resourceId, request: requestPayload, ipAddress: resolveIp(request), - userAgent: request.headers['user-agent'] ?? null, requestId: resolveRequestId(request), durationMs: Date.now() - startedAt, }); @@ -154,10 +143,10 @@ function resolveErrorMessage(error: unknown): string | null { if (error instanceof HttpException) { const response = error.getResponse(); const message = - typeof response === 'string' + typeof response === "string" ? response : ((response as { message?: unknown })?.message ?? error.message); - const text = Array.isArray(message) ? message.join('; ') : String(message); + const text = Array.isArray(message) ? message.join("; ") : String(message); return text.slice(0, MAX_ERROR_LENGTH); } @@ -171,19 +160,19 @@ function resolveErrorMessage(error: unknown): string | null { * entry (the original client) taken. */ function resolveIp(request: Request): string | null { - const forwarded = request.headers['x-forwarded-for']; + const forwarded = request.headers["x-forwarded-for"]; const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; - const candidate = raw?.split(',')[0]?.trim() || request.ip; + const candidate = raw?.split(",")[0]?.trim() || request.ip; if (!candidate) return null; // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column // accepts but which reads badly and breaks grouping by address. - return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; + return candidate.startsWith("::ffff:") ? candidate.slice(7) : candidate; } /** Correlation id from the proxy/tracing layer, when present. */ function resolveRequestId(request: RequestWithUser): string | null { - const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; + const header = request.headers["x-request-id"] ?? request.headers["x-correlation-id"]; const value = Array.isArray(header) ? header[0] : header; return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; } diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index b897c5fef..0195bba47 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -3,6 +3,7 @@ import { InjectDataSource } from '@nestjs/typeorm'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { DataSource } from 'typeorm'; +import type { SnapshotEmployee } from '../../common/freight-jwt.guard'; import { collectPermissionKeys, isSuperAdmin, @@ -82,8 +83,26 @@ export class FreightMeService { ? [employeeRecord.position] : []; - const enrichedPositions = await Promise.all( - rawPositions.map(async (position) => { + // IAM keeps one employee row per organization, so a user holding a freight + // post and a Smart Office post owns two rows. The backoffice reads + // `employee` as an array and the position picker lists what it finds there + // — returning only the active row hides the other desk and makes it + // unselectable. `FreightJwtGuard` leaves the full set here. + const employeeRows = (user as { employeeRows?: SnapshotEmployee[] }) + .employeeRows; + + // Active row first: the backoffice reads `employee[0]` for + // unitId/organizationId, so the desk the caller is acting as must lead. + const rows: SnapshotEmployee[] = employeeRows?.length + ? [ + ...employeeRows.filter((row) => row.id === employeeRecord?.id), + ...employeeRows.filter((row) => row.id !== employeeRecord?.id), + ] + : employeeRecord + ? [{ ...employeeRecord, positions: rawPositions } as SnapshotEmployee] + : []; + + const enrichPosition = async (position: TokenPosition) => { const [positionType, positionTypePermissionKeys] = await Promise.all([ this.lookupPositionType(position.id), this.lookupPositionTypePermissions(position.id), @@ -114,20 +133,24 @@ export class FreightMeService { positionType, }, }; - }), + }; + + const enrichedRows = await Promise.all( + rows.map(async (row) => ({ + row, + positions: await Promise.all( + ((row.positions ?? []) as TokenPosition[]).map(enrichPosition), + ), + })), ); - const employee = employeeRecord - ? [ - { - id: employeeRecord.id, - organizationId: employeeRecord.organizationId, - unitId: employeeRecord.unitId, - name: employeeRecord.name, - positions: enrichedPositions.map((p) => p.position), - }, - ] - : []; + const employee = enrichedRows.map(({ row, positions }) => ({ + id: row.id as string, + organizationId: row.organizationId as string, + unitId: row.unitId as string, + name: row.name, + positions: positions.map((p) => p.position), + })); // `collectPermissionKeys` reads the raw token (position-level only), so // union the type-level grants in — the backoffice prefers this flat list @@ -135,7 +158,9 @@ export class FreightMeService { const permissionKeys = [ ...new Set([ ...collectPermissionKeys(user), - ...enrichedPositions.flatMap((p) => p.positionTypePermissionKeys), + ...enrichedRows.flatMap(({ positions }) => + positions.flatMap((p) => p.positionTypePermissionKeys), + ), ]), ]; diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 06849d560..d29d52c42 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -14,6 +14,8 @@ import { InvoiceLineRepository } from "./invoice-line.repository"; import { PaymentModule } from "../payment/payment.module"; import { CompaniesModule } from "../companies/companies.module"; import { FilesModule } from "../files/files.module"; +import { NotificationsModule } from "../notifications/notifications.module"; +import { NotificationInboxModule } from "../notification-inbox/notification-inbox.module"; @Module({ imports: [ @@ -24,6 +26,10 @@ import { FilesModule } from "../files/files.module"; DocumentsModule, UserTradeAccessModule, FilesModule, + // Customer notice when Finance confirms a manual payment. The inbox module + // reaches this one back through CompaniesModule, hence forwardRef. + NotificationsModule, + forwardRef(() => NotificationInboxModule), ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index f07e6d50f..9a34516aa 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -83,6 +83,8 @@ describe("BillingService.generateInvoice", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); }); @@ -166,6 +168,8 @@ describe("BillingService.issueMemo", () => { {} as never, { get: () => undefined } as never, { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, manager, savedLines }; } @@ -301,6 +305,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -357,6 +363,8 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -403,6 +411,8 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, mg, events }; } @@ -517,6 +527,8 @@ describe("BillingService.recordPayment", () => { {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, mg, events }; } @@ -635,6 +647,8 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, defaultManager, txManager, transaction }; }; @@ -709,6 +723,8 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, manager }; }; @@ -801,6 +817,8 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, repo }; }; @@ -885,6 +903,8 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, repo }; }; @@ -959,6 +979,8 @@ describe("BillingService.document", () => { : undefined, } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { directSend: jest.fn() } as never, // notifications + { notify: jest.fn() } as never, // inbox ); return { service, render, renderThermal }; }; @@ -1079,6 +1101,8 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { function makeService(invoiceType: string) { const invoice = { id: "inv-1", + invoiceNumber: "INV-001", + companyId: "company-1", source: Freight.InvoiceSource.Booking, sourceId: "booking-1", type: invoiceType, @@ -1089,9 +1113,16 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { const recordPayment = jest.fn().mockResolvedValue(invoice); const dataSource = { getRepository: () => ({ - findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }), + findOne: async () => ({ + id: "booking-1", + reference: "BK-001", + paymentDeadline: PAST, + }), }), + query: async () => [{ phone: "+251900000000", email: "c@x.com" }], }; + const directSend = jest.fn().mockResolvedValue(undefined); + const notify = jest.fn().mockResolvedValue(undefined); const service = new BillingService( dataSource as never, { findById: async () => invoice } as never, @@ -1103,10 +1134,12 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { { upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never, { get: () => undefined } as never, { isEnabled: async () => true } as never, + { directSend } as never, + { notify } as never, ); (service as unknown as { recordPayment: unknown }).recordPayment = recordPayment; - return { service, recordPayment }; + return { service, recordPayment, directSend, notify }; } const slip = { originalname: "slip.pdf" } as never; @@ -1129,6 +1162,42 @@ describe("BillingService.confirmOfflinePayment pay-window guard", () => { ); }); + it("notifies the customer (inbox + SMS + email) once the payment is confirmed", async () => { + const { service, notify, directSend } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + await service.confirmOfflinePayment("inv-1", slip, {}); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: { companyId: "company-1" }, + type: "PAYMENT_RECEIVED", + link: "/billing/inv-1", + body: expect.stringMatching(/500 ETB .*INV-001 \(booking BK-001\)/), + }), + ); + expect(directSend).toHaveBeenCalledWith( + "sms", + "+251900000000", + expect.stringContaining("INV-001"), + ); + expect(directSend).toHaveBeenCalledWith( + "email", + "c@x.com", + expect.stringContaining("INV-001"), + ); + }); + + it("still settles when the customer notice fails", async () => { + const { service, notify, recordPayment } = makeService( + WAGON_CANCEL_FEE_INVOICE_TYPE, + ); + notify.mockRejectedValueOnce(new Error("inbox down")); + await expect( + service.confirmOfflinePayment("inv-1", slip, {}), + ).resolves.toBeDefined(); + expect(recordPayment).toHaveBeenCalled(); + }); + it("still requires the bank slip for a cancellation fee", async () => { const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE); await expect( diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 1dd4c5656..ce8c95f40 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,4 +1,9 @@ -import { Freight, PaymentReferenceType } from "@edr/types"; +import { + Freight, + NotificationAudience, + NotificationType, + PaymentReferenceType, +} from "@edr/types"; import { ConfigService } from "@nestjs/config"; import { BadRequestException, @@ -20,6 +25,10 @@ import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wago import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service"; +import { NotificationInboxService } from "../notification-inbox/notification-inbox.service"; +import { NotificationsService } from "../notifications/notifications.service"; +import { sendCompanyChannels } from "../notifications/notify-company.util"; +import { resolveShippingLineNotifyTarget } from "../notifications/resolve-shipping-line-contact.util"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; @@ -33,6 +42,8 @@ import { InvoiceDocumentService, pngDataUrl, } from "./documents/invoice-document.service"; +import { amountInWords } from "./documents/mor-document.util"; +import { buildEimsSeller, resolveLineTax } from "../eims/eims-invoice-context"; import { INVOICE_SORT_COLUMNS } from "./dto/filter-invoice.dto"; import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; @@ -271,7 +282,9 @@ export class BillingService { private readonly files: FilesService, private readonly config: ConfigService, private readonly manualPaymentSettings: ManualPaymentSettingsService, - ) {} + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, + ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -830,8 +843,9 @@ export class BillingService { uploadedByName: input.userName ?? null, }); - return this.recordPayment(invoiceId, { - amount: Number(invoice.balanceAmount), + const amount = Number(invoice.balanceAmount); + const paid = await this.recordPayment(invoiceId, { + amount, method: "BANK_TRANSFER", reference: input.reference || slip.name, metadata: { @@ -841,6 +855,104 @@ export class BillingService { confirmedByName: input.userName ?? null, }, }); + + // The customer did not pay through the portal, so nothing else tells them + // Finance has settled their invoice — this is their only confirmation. + await this.notifyCustomerManualPaymentConfirmed(paid, amount); + return paid; + } + + /** + * Tell the customer Finance confirmed their manual (bank transfer / counter) + * payment: portal inbox entry plus SMS and email to the company's contact + * (or the shipping line's own contact for a credit invoice). Best-effort — + * a notification failure never undoes the settlement, it is only logged. + */ + private async notifyCustomerManualPaymentConfirmed( + invoice: Invoice, + amount: number, + ): Promise { + try { + const bookingRef = + invoice.source === Freight.InvoiceSource.Booking + ? await this.bookingReferenceFor(invoice.sourceId) + : null; + const body = + `Your payment of ${round2(amount)} ${invoice.currency} for invoice ${invoice.invoiceNumber}` + + (bookingRef ? ` (booking ${bookingRef})` : "") + + ` has been received and confirmed. Thank you.`; + const title = "Payment confirmed"; + const data = { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + bookingId: bookingRef ? invoice.sourceId : null, + }; + + if (invoice.companyId || invoice.companyProfileId) { + await this.inbox.notify({ + recipients: invoice.companyId + ? { companyId: invoice.companyId } + : { companyProfileId: invoice.companyProfileId! }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title, + body, + link: `/billing/${invoice.id}`, + data, + }); + if (invoice.companyId) { + await sendCompanyChannels( + this.dataSource, + this.notifications, + invoice.companyId, + body, + ); + } + return; + } + + if (invoice.shippingLineCompanyId) { + const target = await resolveShippingLineNotifyTarget( + this.dataSource, + invoice.shippingLineCompanyId, + ); + if (target.userId) { + await this.inbox.notify({ + recipients: { userIds: [target.userId] }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title, + body, + link: `/shipping-line/invoices/${invoice.id}`, + data, + }); + } + for (const [method, to] of [ + ["sms", target.phone], + ["email", target.email], + ] as const) { + if (!to) continue; + try { + await this.notifications.directSend(method, to, body); + } catch { + /* best-effort: provider unavailable */ + } + } + } + } catch (err) { + this.logger.warn( + `Manual payment confirmed notify failed for invoice ${invoice.id}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Booking reference for a booking id, or null when the booking is gone. */ + private async bookingReferenceFor(bookingId: string): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: bookingId }, + select: ["id", "reference"], + }); + return booking?.reference ?? null; } /** Invoice header plus its line items. */ @@ -1021,18 +1133,133 @@ export class BillingService { currency: invoice.currency, summary, categoryHeader: "Charge type", - lines: invoice.lines.map((l) => ({ - description: l.description ?? l.chargeType, - category: l.chargeType, - quantity: l.quantity, - unitRate: l.unitRate, - amount: l.amount, - currency: l.currency, - })), + lines: invoice.lines.map((l) => { + // Same resolver the filing used, so the printed Tax Code / Excise / Discount columns + // state what MoR actually holds for this line. + const tax = eimsCfg?.invoice ? resolveLineTax(eimsCfg, l.chargeType) : null; + return { + description: l.description ?? l.chargeType, + category: l.chargeType, + quantity: l.quantity, + unitRate: l.unitRate, + amount: l.amount, + currency: l.currency, + nature: eimsCfg?.invoice?.natureOfSupplies ?? null, + uom: eimsCfg?.invoice?.unitDefault ?? null, + taxCode: tax?.code ?? null, + excise: tax?.exciseTaxValue ?? null, + discount: tax?.discount ?? null, + }; + }), totals, qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, + mor: eimsCfg?.invoice ? this.buildMorDetails(invoice, eimsCfg) : null, + }; + } + + /** + * The MoR tax-document view of an invoice (ADD-P001) — the bilingual layout a customer also sees + * when they scan the QR on the Ministry's portal. + * + * Built from the invoice plus EIMS configuration alone, never from a live EIMS call: a document + * has to print whether or not it is registered yet, and printing must not depend on the gateway + * being up. Per-line tax comes from `resolveLineTax`, the same resolver that decided what was + * actually filed, so the paper and the filing cannot disagree. + */ + private buildMorDetails( + invoice: Invoice & { lines: InvoiceLine[] }, + cfg: EimsConfig, + ): InvoiceDocumentModel["mor"] { + const seller = buildEimsSeller(cfg); + const company = invoice.company; + const documentType = (invoice.eimsDocumentType as "INV" | "DEB" | "CRE" | undefined) ?? "INV"; + // CREDIT until the money is in: the title states the sale's payment nature, not its status. + const isCash = Number(invoice.paidAmount) >= Number(invoice.totalAmount); + + const TITLES: Record = { + INV: isCash + ? { am: "የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Cash sales invoice / VAT / Excise Tax" } + : { am: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", en: "Credit sales invoice / VAT / Excise Tax" }, + CRE: { am: "የታክስ ክሬዲት ሰነድ", en: "Tax Credit Note" }, + DEB: { am: "የታክስ ዴቢት ሰነድ", en: "Tax Debit Note" }, + }; + + let total = 0; + let excise = 0; + let discount = 0; + let vatAmount = 0; + let vatTaxable = 0; + for (const line of invoice.lines) { + const tax = resolveLineTax(cfg, line.chargeType); + const lineTotal = Number(line.amount); + total += lineTotal; + excise += tax.exciseTaxValue; + discount += tax.discount; + if (tax.ratePercent > 0) { + vatTaxable += lineTotal; + vatAmount += (lineTotal * tax.ratePercent) / 100; + } + } + const totalIncludingTax = Number(invoice.totalAmount); + const rate = cfg.invoice.taxRatePercent ?? 0; + + const title = TITLES[documentType] ?? TITLES.INV; + + return { + titleAm: title.am, + titleEn: title.en, + saleType: cfg.invoice.transactionType, + irn: invoice.eimsIrn, + systemNumber: cfg.systemNumber || null, + referenceNumber: invoice.eimsDocumentNumber ?? null, + relatedDocumentIrn: invoice.relatedInvoice?.eimsIrn ?? null, + seller: { + name: cfg.invoice.sellerLegalName || seller.LegalName, + city: seller.City, + subCity: seller.SubCity, + woreda: seller.Wereda, + kebele: seller.Locality, + houseNo: seller.HouseNumber, + tin: seller.Tin, + vatNumber: seller.VatNumber, + }, + buyer: { + name: company?.name ?? "N/A", + city: company?.zone ?? null, + subCity: company?.zone ?? null, + woreda: company?.woreda ?? null, + kebele: company?.kebele ?? null, + houseNo: company?.houseNo ?? null, + tin: company?.tin ?? null, + vatNumber: company?.vatNumber ?? null, + }, + tax: { + total: round2(total), + discount: round2(discount), + taxableTotal: round2(vatTaxable), + excise: round2(excise), + vatTaxableAmount: round2(vatTaxable), + // An exempt seller still prints the row, labelled the way the Ministry's portal labels it. + vatLabel: rate > 0 ? `ተ.እ.ታ / VAT ${rate}%` : `${cfg.invoice.taxCode} ታክስ / ${cfg.invoice.taxCode} Tax rate (N/A%)`, + vatAmount: round2(vatAmount), + incomeWithholding: cfg.invoice.incomeWithholdValue ?? 0, + vatWithholding: cfg.invoice.transactionWithholdValue ?? 0, + totalIncludingTax: round2(totalIncludingTax), + amountInWords: amountInWords(totalIncludingTax), + }, + payment: { + mode: isCash ? "CASH" : "CREDIT", + typeMethod: cfg.invoice.paymentTerm, + receiverName: company?.name ?? null, + }, + // A memo is an amendment to a filed document; MoR's layout carries the sign-off that + // authorised it. Names come from the recorded reason until an approval chain exists. + approval: + documentType === "INV" + ? null + : { requestedBy: invoice.eimsReason ?? null, checkedBy: null, approvedBy: null }, }; } diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index c443fad7e..609518eef 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -122,3 +122,168 @@ describe("sameCompanyName", () => { expect(sameCompanyName("ABIJOEL P L C", undefined)).toBe(false); }); }); + +describe("InvoiceDocumentService.buildHtml — MoR tax-document layout (ADD-P001)", () => { + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); + + const mor = (over: Partial> = {}) => + ({ + titleAm: "የዱቤ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ", + titleEn: "Credit sales invoice / VAT / Excise Tax", + saleType: "B2B", + irn: "IRN-123", + systemNumber: "2B6E48BB75", + seller: { name: "Ethio-Djibouti Railway SC", tin: "0053481357" }, + buyer: { name: "Afri Software Solutions", tin: "0089238373" }, + tax: { + total: 904008.15, + discount: 0, + taxableTotal: 0, + excise: 0, + vatTaxableAmount: 0, + vatLabel: "VATEX ታክስ / VATEX Tax rate (N/A%)", + vatAmount: 0, + incomeWithholding: 0, + vatWithholding: 0, + totalIncludingTax: 904008.15, + amountInWords: "Nine hundred and four thousand and eight Birr and fifteen Cents", + }, + payment: { mode: "CREDIT", typeMethod: "IMMIDIATE", receiverName: "Afri Software Solutions" }, + ...over, + }) as NonNullable; + + it("switches layout only when the mor block is present", () => { + expect(service.buildHtml(model())).not.toContain("Total including Tax"); + expect(service.buildHtml(model({ mor: mor() }))).toContain("Total including Tax"); + }); + + it("prints the bilingual title, sale type, IRN and system number", () => { + const html = service.buildHtml(model({ mor: mor() })); + expect(html).toContain("Credit sales invoice / VAT / Excise Tax"); + expect(html).toContain("የዱቤ ሽያጭ ደረሰኝ"); + expect(html).toContain("(B2B)"); + expect(html).toContain("IRN-123"); + expect(html).toContain("2B6E48BB75"); + }); + + it("prints every totals row even when the figure is zero", () => { + const html = service.buildHtml(model({ mor: mor() })); + for (const label of [ + "Discount Amount", + "Taxable Total", + "Excise Tax", + "Total VAT Taxable Amount", + "Total Withheld Amount", + "Total VAT Withheld Amount", + "Total including Tax (in words)", + ]) { + expect(html).toContain(label); + } + }); + + it("renders amounts bare, with the currency named once in the total label", () => { + const html = service.buildHtml(model({ mor: mor() })); + expect(html).toContain("904,008.15"); + expect(html).toContain("Total (ETB)"); + // The generic "1 Birr (ETB)" per-cell format must not leak into the tax layout. + expect(html).not.toContain("904,008.15 Birr (ETB)"); + }); + + it("carries the MoR item columns", () => { + const html = service.buildHtml( + model({ + mor: mor(), + lines: [ + { + description: "Container Import", + quantity: 3, + unitRate: 5223, + amount: 15670, + nature: "service", + uom: "PCS", + taxCode: "VATEX", + excise: 0, + discount: 0, + }, + ], + }), + ); + expect(html).toContain("Tax Code"); + expect(html).toContain("VATEX"); + expect(html).toContain("service"); + expect(html).toContain("PCS"); + }); + + it("shows the related document and approval block on a credit/debit note", () => { + const html = service.buildHtml( + model({ + mor: mor({ + titleEn: "Tax Credit Note", + relatedDocumentIrn: "ORIGINAL-IRN", + approval: { requestedBy: "biruk", checkedBy: "ermias", approvedBy: "kassahun" }, + }), + }), + ); + expect(html).toContain("Related Document"); + expect(html).toContain("ORIGINAL-IRN"); + expect(html).toContain("INVOICE AMENDMENT AUTHORIZATION"); + expect(html).toContain("kassahun"); + }); + + it("renders the sales receipt's linked-invoice table", () => { + const html = service.buildHtml( + model({ + mor: mor({ + titleEn: "Cash Receipt Voucher", + receipt: { + rrn: "RRN-9", + reason: "Payment for goods purchased", + collectedAmount: 950, + invoices: [ + { + irn: "INV-IRN-1", + paymentCoverage: "PARTIAL", + totalAmount: 1200, + remainingAmount: 250, + paidAmount: 950, + }, + ], + }, + }), + }), + ); + expect(html).toContain("RRN-9"); + expect(html).toContain("Payment Coverage"); + expect(html).toContain("PARTIAL"); + expect(html).toContain("Remaining Amount"); + }); + + it("renders the withholding receipt without an item table", () => { + const html = service.buildHtml( + model({ + mor: mor({ + titleEn: "Withholding tax on payment", + tax: null, + withholding: { + receiptNumber: "WH-26-574705075", + counter: "574705075", + reason: "Tax Withholding", + type: "TWTH", + invoiceCurrency: "ETB", + preTaxAmount: 8640000, + withheldAmount: 259200, + systemType: "MAN", + systemNumber: "2B6E48BB75", + }, + }), + lines: [{ description: "ignored", amount: 1 }], + }), + ); + expect(html).toContain("WH-26-574705075"); + expect(html).toContain("TWTH"); + expect(html).toContain("Pre Tax Amount"); + expect(html).toContain("259,200.00"); + // A withholding receipt has no billed items — the item table must be suppressed entirely. + expect(html).not.toContain("Unit Price"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index d85facc0f..ac5da6203 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -5,6 +5,11 @@ import { LogoSettingsService } from "../../logo-settings/logo-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; import { logoImageCss, logoMarkup } from "./logo-markup.util"; +import { + formatDocumentTime, + formatEthiopianDate, + formatGregorianDate, +} from "./mor-document.util"; import { PdfColor, assembleSinglePagePdf, @@ -44,6 +49,21 @@ function money(amount: unknown, currency: string): string { return `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; } +/** + * Bare fixed-2 amount for the MoR tax layout — `1,304,228.00`, no currency suffix. + * + * The Ministry's own documents name the currency once, in the `ድምር (ETB) / Total (ETB)` label, and + * keep every figure a plain right-aligned number. Repeating "Birr (ETB)" in each cell (what the + * generic `money` helper does) both breaks that column alignment and reads as a different + * document from the one the customer sees when they scan the QR. + */ +function amount2(value: unknown): string { + return Number(value ?? 0).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); +} + function formatDate(value: unknown): string { return value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; } @@ -85,6 +105,115 @@ export interface InvoiceDocumentLine { unitRate?: number | null; amount?: number | null; currency?: string | null; + /** + * MoR tax-document columns (ADD-P001). Populated only for documents that carry a + * {@link MorDocumentDetails}; the generic EDR layout ignores them. + */ + nature?: string | null; + uom?: string | null; + taxCode?: string | null; + excise?: number | null; + discount?: number | null; +} + +/** One party block (`ከ / From`, `ለ / To`) of a MoR tax document. */ +export interface MorPartyDetails { + name: string; + city?: string | null; + /** `ዞን / ክ/ከተማ` — Zone/Sub city. */ + subCity?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + tin?: string | null; + subTin?: string | null; + vatNumber?: string | null; +} + +/** + * The Ministry's totals block, in its printed order. Every row prints even at zero — a tax + * document states each figure explicitly rather than omitting the ones that happen to be nil. + */ +export interface MorTaxSummary { + total: number; + discount: number; + taxableTotal: number; + excise: number; + vatTaxableAmount: number; + /** e.g. `ተ.እ.ታ / VAT 15%`, or `VATEX ታክስ / VATEX Tax rate (N/A%)` for an exempt seller. */ + vatLabel: string; + vatAmount: number; + incomeWithholding: number; + vatWithholding: number; + totalIncludingTax: number; + amountInWords: string; +} + +export interface MorPaymentDetails { + /** `CASH` / `CREDIT` — also selects the document title. */ + mode: string; + /** `IMMEDIATE` and friends. */ + typeMethod: string; + receiverName?: string | null; +} + +/** Credit/debit memo authorisation block. */ +export interface MorApprovalDetails { + requestedBy?: string | null; + checkedBy?: string | null; + approvedBy?: string | null; +} + +/** Sales receipt (CRV) specifics. */ +export interface MorReceiptDetails { + rrn: string; + reason: string; + collectedAmount: number; + invoices: Array<{ + irn: string; + paymentCoverage: string; + totalAmount: number; + remainingAmount: number; + paidAmount: number; + }>; +} + +/** Withholding receipt specifics — a different document shape, with no item table. */ +export interface MorWithholdingDetails { + receiptNumber: string; + counter: string; + reason: string; + /** MoR withholding type, e.g. `TWTH`. */ + type: string; + invoiceCurrency: string; + preTaxAmount: number; + withheldAmount: number; + systemType: string; + systemNumber: string; +} + +/** + * Everything the MoR (ADD-P001) print layout needs beyond the generic model. Present ⇒ the + * document renders in the Ministry's bilingual tax-document format instead of the plain EDR one. + */ +export interface MorDocumentDetails { + /** Bilingual heading, e.g. `የእጅ በእጅ ሽያጭ ደረሰኝ / ተ.እ.ታ / ኤክሳይዝ ታክስ` + `Cash sales invoice / VAT / Excise Tax`. */ + titleAm: string; + titleEn: string; + /** `B2B` / `B2C` / `B2G`. */ + saleType?: string | null; + irn?: string | null; + systemNumber?: string | null; + referenceNumber?: string | null; + /** Original document's IRN — credit and debit notes only. */ + relatedDocumentIrn?: string | null; + seller: MorPartyDetails; + buyer: MorPartyDetails; + tax?: MorTaxSummary | null; + payment?: MorPaymentDetails | null; + approval?: MorApprovalDetails | null; + receipt?: MorReceiptDetails | null; + withholding?: MorWithholdingDetails | null; } /** A labelled total row in the totals box; mark `grand` for the headline total. */ @@ -129,6 +258,12 @@ export interface InvoiceDocumentModel { * itself goes through the ordinary `summary` rows, not a dedicated field. */ qrImageUrl?: string | null; + /** + * Present ⇒ render the Ministry's bilingual tax-document layout (ADD-P001) rather than the + * generic EDR one. Set for every document EIMS knows about: invoice, credit/debit note, sales + * receipt and withholding receipt. + */ + mor?: MorDocumentDetails | null; } /** @@ -232,12 +367,38 @@ export class InvoiceDocumentService { }) .join(""); - const totalRows = model.totals - .map( - (total) => - `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, - ) - .join(""); + // A thermal receipt is a compact derivative of the A4 tax document, not a different document: + // the tax breakdown, the amount in words and the payment mode are the legally load-bearing + // parts and must survive the narrower page. Only the item-table columns are dropped. + const tax = model.mor?.tax; + const totalRows = tax + ? [ + ["Total", money(tax.total, model.currency)], + ["Discount", money(tax.discount, model.currency)], + ["Taxable Total", money(tax.taxableTotal, model.currency)], + ["Excise Tax", money(tax.excise, model.currency)], + [tax.vatLabel, money(tax.vatAmount, model.currency)], + ["Withheld", money(tax.incomeWithholding, model.currency)], + ["VAT Withheld", money(tax.vatWithholding, model.currency)], + ] + .map( + ([label, value]) => + `
${esc(label)}${esc(value)}
`, + ) + .join("") + + `
Total incl. Tax${esc(money(tax.totalIncludingTax, model.currency))}
` + + `
${esc(tax.amountInWords)}
` + : model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount, model.currency))}
`, + ) + .join(""); + + const payMarkup = model.mor?.payment + ? `
Mode of Payment${esc(model.mor.payment.mode)}
+
Type/Method${esc(model.mor.payment.typeMethod)}
` + : ""; const qrMarkup = model.qrImageUrl ? `
EIMS verification QR
Scan to verify (MoR EIMS)
` @@ -264,6 +425,7 @@ export class InvoiceDocumentService { .item-calc { text-align: right; font-family: monospace; font-size: 8.5px; } .total-row { display: flex; justify-content: space-between; font-size: 9px; padding: 2px 0; } .total-row.grand { font-size: 11px; font-weight: 800; border-top: 1px solid #0f172a; margin-top: 3px; padding-top: 4px; } + .words { font-size: 8px; text-align: center; margin-top: 4px; font-style: italic; } .qr { text-align: center; margin: 8px 0; } .qr img { width: 150px; height: 150px; } .qr-caption { font-size: 7px; color: #64748b; margin-top: 2px; } @@ -282,6 +444,7 @@ export class InvoiceDocumentService { ${itemBlocks}
${totalRows} + ${payMarkup} ${qrMarkup} @@ -414,6 +577,10 @@ export class InvoiceDocumentService { } buildHtml(model: InvoiceDocumentModel): string { + // A MoR-registered document prints in the Ministry's own bilingual format (ADD-P001). Anything + // else — internal fee notes, statements — keeps the plain EDR layout below. + if (model.mor) return this.buildMorHtml(model, model.mor); + const date = formatDate; const showCategory = Boolean(model.categoryHeader); const sealText = @@ -531,7 +698,317 @@ export class InvoiceDocumentService { `; } + /** + * MoR EIMS tax-document layout (ADD-P001) — invoice, credit/debit note, sales receipt and + * withholding receipt share this one template, differing only in which optional blocks appear. + * + * Field labels and their order come from the Ministry's own portal rendering of a registered EDR + * invoice, so a printout and the page a customer reaches by scanning the QR read the same way. + * Every totals row prints even at zero: a tax document states each figure rather than hiding the + * nil ones. + */ + buildMorHtml(model: InvoiceDocumentModel, mor: MorDocumentDetails): string { + const currency = model.currency; + const party = (p: MorPartyDetails, sideAm: string, sideEn: string, tinAm: string, tinEn: string): string => ` + + + + ${morRow("ከተማ", "City/Town", p.city)} + ${morRow("ዞን / ክ/ከተማ", "Zone/Sub city", p.subCity)} + ${morRow("ወረዳ", "Woreda", p.woreda)} + ${morRow("ቀበሌ", "Kebele", p.kebele)} + ${morRow("የቤ/ቁ", "H/No", p.houseNo)} + ${morRow("የግብር ከፋይ መለያ ቁጥር", `${tinEn}'s TIN`, p.tin, tinAm)} + ${morRow("ንዑስ/ቁ", "Sub-TIN", p.subTin)} + ${morRow("ተ.እ.ታ ቁጥር", `${tinEn}'s VAT`, p.vatNumber)} +
${esc(sideAm)}${esc(sideEn)}${esc(p.name)}
`; + + const itemRows = model.lines + .map( + (item, i) => ` + ${i + 1} + ${esc(item.description)} + ${esc(item.nature ?? "-")} + ${esc(item.uom ?? "-")} + ${esc(item.quantity ?? 0)} + ${esc(amount2(item.unitRate))} + ${esc(item.taxCode ?? "-")} + ${esc(amount2(item.excise ?? 0))} + ${esc(amount2(item.discount ?? 0))} + ${esc(amount2(item.amount))} + `, + ) + .join(""); + + const tax = mor.tax; + const taxRows = tax + ? [ + totalRow("ድምር", `Total (${currency})`, amount2(tax.total)), + totalRow("የቅናሽ መጠን", "Discount Amount", amount2(tax.discount)), + totalRow("ታክስ የሚከፈልበት ድምር", "Taxable Total", amount2(tax.taxableTotal)), + totalRow("ኤክሳይዝ ታክስ", "Excise Tax", amount2(tax.excise)), + totalRow("ተ.እ.ታ የሚከፈልበት ድምር", "Total VAT Taxable Amount", amount2(tax.vatTaxableAmount)), + totalRow("", tax.vatLabel, amount2(tax.vatAmount)), + totalRow("ጠቅላላ የተያዘ መጠን", "Total Withheld Amount", amount2(tax.incomeWithholding)), + totalRow("ጠቅላላ የተያዘ መጠን ተ.እ", "Total VAT Withheld Amount", amount2(tax.vatWithholding)), + totalRow("ጠቅላላ ዋጋ ከታክስ ጋር", "Total including Tax", amount2(tax.totalIncludingTax), true), + ].join("") + : ""; + + const wordsRow = tax + ? `ጠቅላላ ዋጋ ከታክስ ጋር (በፊደል)Total including Tax (in words) + ${esc(tax.amountInWords)}` + : ""; + + const receipt = mor.receipt; + const receiptBlock = receipt + ? ` + ${morRow("የክፍያ ምክንያት", "Payment Reason", receipt.reason)} + ${morRow("የተሰበሰበ መጠን", "Collected Amount", amount2(receipt.collectedAmount))} +
+
የደረሰኞች ዝርዝር / Invoices
+ + + + + + + + + ${receipt.invoices + .map( + (inv) => ` + + + + + + `, + ) + .join("")} +
IRN${esc("የክፍያ ሽፋን / Payment Coverage")}${esc("ጠቅላላ ዋጋ / Total Amount")}${esc("ቀሪ / Remaining Amount")}${esc("የተከፈለ / Paid Amount")}
${esc(inv.irn)}${esc(inv.paymentCoverage)}${esc(amount2(inv.totalAmount))}${esc(amount2(inv.remainingAmount))}${esc(amount2(inv.paidAmount))}
+ ` + : ""; + + const wh = mor.withholding; + const withholdingBlock = wh + ? ` + ${morRow("የደረሰኝ ቁጥር", "Receipt #", wh.receiptNumber)} + ${morRow("ቆጣሪ", "Counter", wh.counter)} + ${morRow("ምክንያት", "Reason", wh.reason)} + ${morRow("አይነት", "Type", wh.type)} +
+ + + + + + + + + + + + + +
${esc("የደረሰኝ ቁጥር / Invoice Doc. Number")}${esc("የገንዘብ ዓይነት / Invoice Currency")}${esc("ከታክስ በፊት ያለው ዋጋ / Pre Tax Amount")}${esc("ተይዞ የቀረ መጠን / Withheld Amount")}
${esc(wh.receiptNumber)}${esc(wh.invoiceCurrency)}${esc(amount2(wh.preTaxAmount))}${esc(amount2(wh.withheldAmount))}
+ + + ${morRow("የስርዓት አይነት", "System Type", wh.systemType)} + ${morRow("የስርዓት ቁጥር", "System Number", wh.systemNumber)} +
` + : ""; + + const payment = mor.payment; + const paymentBlock = payment + ? ` + + + + + +
የክፍያ ሁኔታMode of Payment${esc(payment.mode)}አይነትType/Method${esc(payment.typeMethod)}የተቀባይ ስምና ፊርማReceiver Name & Signature${esc(payment.receiverName ?? "")}
` + : ""; + + const approval = mor.approval; + const approvalBlock = approval + ? `
INVOICE AMENDMENT AUTHORIZATION
+
This amendment has been reviewed and approved in accordance with the company's approval matrix.
+ + + + + + +
የጠየቀውRequested By${esc(approval.requestedBy ?? "")}ያረጋገጠውChecked By${esc(approval.checkedBy ?? "")}ያፀደቀውApproved By${esc(approval.approvedBy ?? "")}
` + : ""; + + const qrBlock = model.qrImageUrl + ? `EIMS verification QR` + : ""; + + return ` + + + + ${esc(mor.titleEn)} ${esc(model.documentNumber)} + + + +
+
+
+ ${model.logoImageUrl ? `` : ""} +
${esc(mor.seller.name)}
+
Ethio-Djibouti Railway S.C.
+
+
+
የደረሰኝ ቁጥርDocument No${esc(model.documentNumber)}
+
ቀንDate${esc(formatEthiopianDate(model.issuedAt))}
+
${esc(formatGregorianDate(model.issuedAt))}
+
ሰአትTime${esc(formatDocumentTime(model.issuedAt))}
+
+
+ +
+
${esc(mor.titleAm)}
+
${esc(mor.titleEn)}
+ ${mor.saleType ? `
የሽያጭ አይነት (${esc(mor.saleType)})
` : ""} +
+ +
+ + ${mor.irn ? `` : ""} + ${mor.receipt ? `` : ""} + ${mor.systemNumber ? `` : ""} + ${mor.referenceNumber ? `` : ""} + ${mor.relatedDocumentIrn ? `` : ""} +
IRN${esc(mor.irn)}
RRN${esc(mor.receipt.rrn)}
System Number${esc(mor.systemNumber)}
Reference Number${esc(mor.referenceNumber)}
Related Document${esc(mor.relatedDocumentIrn)}
+ ${qrBlock} +
+ +
+
${party(mor.seller, "ከ", "From", "የሻጭ", "Seller")}
+
${party(mor.buyer, "ለ", "To", "የገዢ", "Customer")}
+
+ + ${withholdingBlock} + ${receiptBlock} + + ${ + model.lines.length > 0 && !mor.withholding + ? ` + + + + + + + + + + + + + + + ${itemRows} +
${esc("ተ/ቁ")}
No.
${esc("የዕቃው / አገልግሎት አይነት")}
Description
${esc("ምድብ")}
Nature
${esc("መለኪያ")}
UoM
${esc("ብዛት")}
Qty
${esc("የአንዱ ዋጋ")}
Unit Price
${esc("ታክስ ኮድ")}
Tax Code
${esc("ኤክሳይዝ")}
Excise
${esc("ቅናሽ")}
Discount
${esc("ጠቅላላ ዋጋ")}
Total Amount
` + : "" + } + + ${tax ? `${taxRows}${wordsRow}
` : ""} + ${paymentBlock} + ${approvalBlock} + +
+
Ethio-Djibouti Railway S.C. — ${esc(mor.titleEn)}
+
Page 1 of 1  ·  Printed ${esc(formatGregorianDate(new Date()))} ${esc(formatDocumentTime(new Date()))}
+
+
+ +`; + } + safeFilename(value: string): string { return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); } } + +/** One bilingual label/value row inside a party or key-value table. */ +function morRow(am: string, en: string, value: unknown, amOverride?: string): string { + return `${esc(amOverride ? `${amOverride} ${am}` : am)}${esc(en)}${esc( + value === null || value === undefined || value === "" ? "N/A" : value, + )}`; +} + +/** One row of the Ministry's totals block. */ +function totalRow(am: string, en: string, value: string, grand = false): string { + return `${esc(am ? `${am} / ${en}` : en)}${esc(value)}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts new file mode 100644 index 000000000..ab9ad6791 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.spec.ts @@ -0,0 +1,65 @@ +import { + amountInWords, + formatEthiopianDate, + formatGregorianDate, + gregorianToEthiopian, + numberToWords, +} from "./mor-document.util"; + +describe("gregorianToEthiopian", () => { + it("matches the MoR portal's own rendering of a registered EDR invoice", () => { + // portal.mor.gov.et printed `25-12-2018 ዓ/ም` beside `31-08-2026 G.C` for INV document no. 3. + expect(gregorianToEthiopian(new Date(2026, 7, 31))).toEqual({ year: 2018, month: 12, day: 25 }); + expect(formatEthiopianDate(new Date(2026, 7, 31))).toBe("25-12-2018 ዓ/ም"); + expect(formatGregorianDate(new Date(2026, 7, 31))).toBe("31-08-2026 G.C"); + }); + + it("rolls the year on Ethiopian new year, not on the Gregorian one", () => { + // 11 Sep 2026 is 1 መስከረም 2019; the day before is still 2018. + expect(gregorianToEthiopian(new Date(2026, 8, 10))).toMatchObject({ year: 2018, month: 13 }); + expect(gregorianToEthiopian(new Date(2026, 8, 11))).toEqual({ year: 2019, month: 1, day: 1 }); + }); + + it("returns a placeholder rather than throwing on a missing date", () => { + expect(formatEthiopianDate(null)).toBe("-"); + expect(formatGregorianDate(undefined)).toBe("-"); + }); +}); + +describe("amountInWords", () => { + it("spells an amount with cents the way the reference tax invoice does", () => { + // WISCOM's certified printout: 3,759.93 -> "three thousand seven hundred and fifty-nine Birr + // and ninety-three Cents". + expect(amountInWords(3759.93)).toBe( + "Three thousand seven hundred and fifty-nine Birr and ninety-three Cents", + ); + }); + + it("keeps the 'and' inside a scale group, as the reference printouts do", () => { + // 407,422.98 on the reference credit-sales invoice reads "Four Hundred And Seven Thousand Four + // Hundred And Twenty-Two Birr and Ninety-Eight Cents". Note the MoR portal itself uses the + // other convention ("nine hundred four thousand"); the printed document follows the reference. + expect(amountInWords(407422.98)).toBe( + "Four hundred and seven thousand four hundred and twenty-two Birr and ninety-eight Cents", + ); + }); + + it("omits the cents clause on a whole amount", () => { + expect(amountInWords(880)).toBe("Eight hundred and eighty Birr"); + }); + + it("carries rounded cents into the Birr instead of printing 100 Cents", () => { + expect(amountInWords(9.999)).toBe("Ten Birr"); + }); + + it("handles zero and sub-Birr amounts", () => { + expect(amountInWords(0)).toBe("Zero Birr"); + expect(amountInWords(0.5)).toBe("Zero Birr and fifty Cents"); + }); + + it("spells the scale words", () => { + expect(numberToWords(1_000_000)).toBe("one million"); + expect(numberToWords(21)).toBe("twenty-one"); + expect(numberToWords(115)).toBe("one hundred and fifteen"); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts new file mode 100644 index 000000000..ba74737b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/mor-document.util.ts @@ -0,0 +1,178 @@ +/** + * Presentation helpers for MoR EIMS tax documents (ADD-P001 print layout). + * + * The layout these serve is modelled on the Ministry's own portal rendering of a registered EDR + * invoice (portal.mor.gov.et), which is the authoritative source for the bilingual field labels — + * not on any one vendor's template. + */ + +/** Ethiopian month names, index 0 = መስከረም. */ +const ETHIOPIAN_MONTHS = [ + "መስከረም", + "ጥቅምት", + "ኅዳር", + "ታኅሣሥ", + "ጥር", + "የካቲት", + "መጋቢት", + "ሚያዝያ", + "ግንቦት", + "ሰኔ", + "ሐምሌ", + "ነሐሴ", + "ጳጉሜ", +] as const; + +export interface EthiopianDate { + year: number; + month: number; + day: number; +} + +/** + * Gregorian → Ethiopian, via Julian Day Number. + * + * JDN rather than day-of-year arithmetic because the Ethiopian new year drifts against September + * 11/12 on the Gregorian leap cycle; JDN is the same conversion the passenger portal already uses. + */ +export function gregorianToEthiopian(date: Date): EthiopianDate { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + const a = Math.floor((14 - month) / 12); + const y = year + 4800 - a; + const m = month + 12 * a - 3; + const jdn = + day + + Math.floor((153 * m + 2) / 5) + + 365 * y + + Math.floor(y / 4) - + Math.floor(y / 100) + + Math.floor(y / 400) - + 32045; + + // 1723856 is the JDN of 1 መስከረም 1 E.C. + const r = (jdn - 1723856) % 1461; + const n = (r % 365) + 365 * Math.floor(r / 1460); + const ethYear = 4 * Math.floor((jdn - 1723856) / 1461) + Math.floor(r / 365) - Math.floor(r / 1460); + const ethMonth = Math.floor(n / 30) + 1; + const ethDay = (n % 30) + 1; + + return { year: ethYear, month: ethMonth, day: ethDay }; +} + +/** `25-12-2018 ዓ/ም` — the numeric form the MoR portal prints beside the Gregorian date. */ +export function formatEthiopianDate(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const { year, month, day } = gregorianToEthiopian(date); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(day)}-${pad(month)}-${year} ዓ/ም`; +} + +/** `ሐምሌ 25, 2018` — the long form, when a document has room for it. */ +export function formatEthiopianDateLong(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const { year, month, day } = gregorianToEthiopian(date); + return `${ETHIOPIAN_MONTHS[month - 1] ?? ""} ${day}, ${year}`; +} + +/** `31-08-2026 G.C` — Gregorian, labelled the way the MoR portal labels it. */ +export function formatGregorianDate(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(date.getDate())}-${pad(date.getMonth() + 1)}-${date.getFullYear()} G.C`; +} + +/** `10:58:30`, 24-hour, to match the portal's `ሰአት/Time` row. */ +export function formatDocumentTime(value: Date | string | null | undefined): string { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) return "-"; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +const ONES = [ + "", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", +]; +const TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"]; +const SCALES: [number, string][] = [ + [1_000_000_000, "billion"], + [1_000_000, "million"], + [1_000, "thousand"], +]; + +/** 0-999 in words. */ +function underThousand(value: number): string { + if (value < 20) return ONES[value]; + if (value < 100) { + const rest = value % 10; + return TENS[Math.floor(value / 10)] + (rest ? `-${ONES[rest]}` : ""); + } + const rest = value % 100; + return `${ONES[Math.floor(value / 100)]} hundred${rest ? ` and ${underThousand(rest)}` : ""}`; +} + +/** Whole number in words. Returns "zero" for 0. */ +export function numberToWords(value: number): string { + const n = Math.floor(Math.abs(value)); + if (n === 0) return "zero"; + + const parts: string[] = []; + let remaining = n; + for (const [scale, name] of SCALES) { + const count = Math.floor(remaining / scale); + if (count > 0) { + parts.push(`${numberToWords(count)} ${name}`); + remaining %= scale; + } + } + if (remaining > 0) { + // "and" only before a trailing sub-hundred group, matching how the amount reads aloud + // ("three thousand seven hundred and fifty-nine", not "three thousand and seven hundred"). + parts.push(parts.length > 0 && remaining < 100 ? `and ${underThousand(remaining)}` : underThousand(remaining)); + } + return parts.join(" "); +} + +/** + * `Total including Tax (in words)` — the legally required spelling-out of the payable amount. + * + * Computed here rather than read back from MoR: the Ministry renders its own copy on the portal, + * but returns nothing carrying it on `/v1/register`, and the line has to print on a document that + * may not be registered yet. + */ +export function amountInWords(value: number, currencyLabel = "Birr", fractionLabel = "Cents"): string { + const amount = Number.isFinite(value) ? Math.abs(value) : 0; + const birr = Math.floor(amount); + // Round the remainder rather than truncate: 0.155 must read as sixteen cents, not fifteen. + const cents = Math.round((amount - birr) * 100); + // Rounding cents can carry into the next Birr (x.999 -> 100 cents). + const [wholeBirr, wholeCents] = cents === 100 ? [birr + 1, 0] : [birr, cents]; + + const head = `${numberToWords(wholeBirr)} ${currencyLabel}`; + const text = wholeCents > 0 ? `${head} and ${numberToWords(wholeCents)} ${fractionLabel}` : head; + return text.charAt(0).toUpperCase() + text.slice(1); +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 264ffb811..7324da975 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -370,7 +370,21 @@ export class BookingTransitionService { async startTransit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ["PAID"]); + // Paid is read from the PAYMENT status only; the booking status merely + // guards against re-entering transit from a later stage. + if (booking.paymentStatus !== "PAID") { + throw new ConflictException( + `Booking must be paid before it can start transit (payment status "${booking.paymentStatus ?? "PENDING"}")`, + ); + } + assertBookingStatus(booking, [ + "PAID", + "FULLY_EXECUTED", + "PNR_GENERATED", + "WAGON_ASSIGNED", + "READY_FOR_ASSIGNMENT", + "APPROVED", + ]); const updated = await this.bookingsRepository.update(bookingId, { status: "IN_TRANSIT", @@ -1739,6 +1753,7 @@ export class BookingTransitionService { // (portal and backoffice). Degrades to null like every fragile field here. let trainSchedule: { trainNumber: string | null; + voyageNumber: string | null; reference: string | null; scheduledDepartureDate: Date | null; } | null = null; @@ -1750,6 +1765,8 @@ export class BookingTransitionService { if (s) { trainSchedule = { trainNumber: s.trainNumber ?? null, + // The schedule's own voyage (sailing) number shown to the customer. + voyageNumber: s.voyageNumber ?? null, reference: s.reference ?? null, scheduledDepartureDate: s.scheduledDepartureDate ?? null, }; 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 66ee2cbbb..b08b7e8c5 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 @@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { wagons: number; weightTons: number; quantities: { bulkTons?: number }; + totalWagons: number; }>; }; const booking = { @@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => { it('cancels every wagon with the exact total tonnage', async () => { const cut = await svc.resolveRequestedCut(booking, { wagons: 4 }); - expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } }); + expect(cut).toEqual({ + wagons: 4, + weightTons: 250.5, + quantities: { bulkTons: 250.5 }, + totalWagons: 4, + }); + }); + + /** + * Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so + * cancellation used to reject it outright ("no wagon requirement to cancel + * from"). The pinned `cancellationWagons`, stamped at first allocation, keeps + * the footprint through the unassign. + */ + it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => { + const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 }; + const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 }); + expect(cut.wagons).toBe(4); + expect(cut.totalWagons).toBe(4); + expect(cut.weightTons).toBe(250.5); + }); + + /** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */ + it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => { + const fresh = { + ...booking, + wagonsRequired: null, + cancellationWagons: null, + bulkRequestedWagons: 3, + }; + const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 }); + expect(cut.totalWagons).toBe(3); + expect(cut.weightTons).toBe(250.5); }); it('rejects more wagons than the booking has', async () => { @@ -91,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = }; it('refuses an odd-20ft rebook without a GL-picked partner', async () => { + // An odd credit always leaves a half-empty wagon, so GL must name who fills + // it — the rebook is refused rather than shipping a half-empty wagon. await expect( makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }), ).rejects.toThrow(/pick a consolidation partner/i); @@ -110,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () = }), ).rejects.toThrow(/already shares a wagon/i); }); + + /** + * An EXPIRED partner has no pay window left, so pairing the PAID rebook + * straight onto it strands the shared wagon: neither half can board and + * nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a + * fresh booking that carries its own invoice. + */ + it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => { + const dead = { + id: 'p1', + reference: 'BK-2026-001114', + status: 'EXPIRED', + contractId: 'c1', + consolidationPartnerId: null, + paymentCurrency: 'USD', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + scheduledDate: '2026-09-01', + bookingContainers: [ + { + containerSize: '20ft', + quantity: 1, + hazardousQuantity: 0, + reeferQuantity: 0, + containerType: { sizeFt: 20 }, + units: [ + { + containerNumber: 'PCONT0', + sealNumber: null, + vgmTons: 9, + isHazardous: false, + isReefer: false, + }, + ], + }, + ], + }; + const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' }; + + const svc = makeSvc(dead) as Record; + let createdUnderContract: string | null = null; + let pairedWith: string | null = null; + (svc as { bookingsRepository: Record }).bookingsRepository = { + findById: async () => source, + findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead), + hasSpentCancellationCredit: async () => false, + }; + (svc as { contractBooking: unknown }).contractBooking = { + createUnderContract: async (contractId: string) => { + createdUnderContract = contractId; + return { booking: { id: 'p1-clone' } }; + }, + }; + (svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined; + + const cloned = await ( + svc as unknown as { + cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>; + } + ).cloneDeadPartner(dead, '2026-09-01'); + + // The dead booking is left dead; the clone is what gets paired and paid. + expect(cloned.id).toBe('p1-clone'); + expect(cloned.reference).toBe('BK-2026-001116'); + expect(createdUnderContract).toBe('c1'); + expect(pairedWith).toBeNull(); + }); }); /** @@ -199,3 +302,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () expect(dto.requestedWagons).toBeUndefined(); }); }); + +/** + * The cancellation fee is paid BEFORE the credit is redeemed. + * + * An at-loading cut applies immediately and opens the credit while its fee + * invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was + * settled. Without the gate the customer rebooks the same wagons and the + * cancellation fee is simply never collected. EDR-fault cuts carry no fee and + * must stay freely rebookable — partial or whole, container or bulk. + */ +describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => { + const source = { + id: 'b1', + contractId: 'c1', + paymentCurrency: 'ETB', + originYardId: 'y1', + destinationYardId: 'y2', + tradeDirection: 'IMPORT', + }; + + const makeSvc = (row: Record) => { + 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 () => null, + }; + return svc; + }; + + /** Bulk credit — no bySize, so nothing depends on container snapshots. */ + const bulkRow = (over: Record) => ({ + id: 'wc1', + bookingId: 'b1', + status: 'CREDIT_AVAILABLE', + creditAmount: 5000, + wagonsCancelled: 2, + cancelledQuantities: { bulkTons: 100 }, + feeCurrency: 'ETB', + ...over, + }); + + it('blocks a rebook while a customer-fault fee is unpaid', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }), + ); + await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow( + /pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i, + ); + }); + + it('blocks a WHOLE-booking customer-fault cancel just the same', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }), + ); + await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow( + /4 wagon\(s\) before rebooking/i, + ); + }); + + it('lets the rebook through once the fee is paid', async () => { + const svc = makeSvc( + bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }), + ); + // Past the gate it fails later (no contract/create wiring in this harness) — + // what matters is that it is no longer the fee that stops it. + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/i); + }); + + it('never charges an EDR-fault cut', async () => { + const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null })); + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/i); + }); + + it('leaves legacy rows without a fee untouched', async () => { + const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null })); + await expect( + svc.rebook('wc1', { scheduledDate: '2026-09-01' }), + ).rejects.not.toThrow(/cancellation fee/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 86b9dedc6..983564a51 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 @@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { requestedBulkWagons } from '../train-scheduling/train-capacity.util'; +import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -51,6 +53,8 @@ import { CancelledUnitSnapshot, WAGON_CANCEL_FEE_INVOICE_TYPE, } from './entities/booking-wagon-cancellation.entity'; +import { WagonEventType } from '@edr/types'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; export { WAGON_CANCEL_FEE_INVOICE_TYPE }; @@ -74,6 +78,8 @@ interface RequestedCut { wagons: number; weightTons: number; quantities: CancelledQuantities; + /** The booking's whole wagon footprint the cut came out of — credit divides by it. */ + totalWagons: number; } /** The priced fee for a cut: total, currency and the rate(s) it came from. */ @@ -130,6 +136,7 @@ export class BookingWagonCancellationService { private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, private readonly events: EventEmitter2, + private readonly wagonHistory: WagonHistoryService, ) {} // ── T1: request ──────────────────────────────────────────────────────────── @@ -165,7 +172,7 @@ export class BookingWagonCancellationService { feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, - creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + creditAmount: round2(Number(booking.totalAmount ?? 0)), }; } this.assertCutSparesSharedWagon(cut); @@ -177,7 +184,7 @@ export class BookingWagonCancellationService { feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, - creditAmount: this.creditFor(booking, cut.wagons), + creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons), }; } @@ -218,7 +225,7 @@ export class BookingWagonCancellationService { : await this.resolveRequestedCut(booking, dto); const fee = await this.priceFee(booking, cut); const feeAmount = fee.amount; - const creditAmount = this.creditFor(booking, cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, @@ -318,7 +325,7 @@ export class BookingWagonCancellationService { const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId: row.bookingId }, }); - if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) { + if (rows < Math.round(await this.wagonFootprint(booking))) { throw new ConflictException( 'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.', ); @@ -363,7 +370,7 @@ export class BookingWagonCancellationService { const row = await this.openConsolidationBreak( booking, 'ceil', - this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + round2(Number(booking.totalAmount ?? 0)), reason ?? 'Consolidated pair cancelled', userId, ); @@ -371,7 +378,7 @@ export class BookingWagonCancellationService { await this.openConsolidationBreak( partner, 'floor', - this.creditFor(partner, Number(partner.wagonsRequired ?? 0)), + round2(Number(partner.totalAmount ?? 0)), `Cancelled with its consolidation partner ${booking.reference}`, userId, ); @@ -540,7 +547,8 @@ export class BookingWagonCancellationService { } as RequestWagonCancellationDto); } return this.resolveRequestedCut(booking, { - wagons: Number(booking.wagonsRequired ?? 0), + // Footprint, not the live wagonsRequired: unassign clears that to NULL. + wagons: await this.wagonFootprint(booking), } as RequestWagonCancellationDto); } @@ -568,7 +576,7 @@ export class BookingWagonCancellationService { const row = await this.openConsolidationBreak( booking, 'ceil', - this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + round2(Number(booking.totalAmount ?? 0)), 'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies', ); await this.dataSource.getRepository(Booking).update(booking.id, { @@ -729,9 +737,10 @@ export class BookingWagonCancellationService { // Whole-booking cut: nothing is left to ship, so the booking ends // CANCELLED (frees the contract slot/cap for the rebook) and drops off its // train. The credit row still points at it for T3. - const wagonsLeft = round2( - Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), - ); + // Off the pinned footprint, not the live wagonsRequired — unassign + // clears that to NULL, which read as a full cut on any partial cancel. + const footprint = await this.wagonFootprint(booking); + const wagonsLeft = round2(footprint - Number(row.wagonsCancelled)); const isFull = wagonsLeft <= 0; // NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which // bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the @@ -745,6 +754,9 @@ export class BookingWagonCancellationService { : null; await manager.getRepository(Booking).update(booking.id, { wagonsRequired: Math.max(0, wagonsLeft), + // Keep the cancellation footprint in step, so a second partial cancel + // prices against what is actually left, not the original booking. + cancellationWagons: Math.max(0, wagonsLeft), ...(requestedWagonsLeft !== null ? { bulkRequestedWagons: requestedWagonsLeft } : {}), @@ -853,14 +865,37 @@ export class BookingWagonCancellationService { ); } + // Staff may cut a SUBSET of the never-loaded wagons (picked in the loading + // modal) instead of the whole remainder. Anything already LOADED is + // rejected rather than silently dropped: the operator believes they are + // cancelling that wagon, and it is on the train. + let target = remaining; + if (dto.wagonAllocationIds?.length) { + const wanted = new Set(dto.wagonAllocationIds); + const known = new Set(allocations.map((a) => a.id)); + const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id)); + if (unknown.length) { + throw new BadRequestException( + 'Some selected wagons are not allocated to this booking on this schedule.', + ); + } + const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a)); + if (loaded.length) { + throw new BadRequestException( + `${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`, + ); + } + target = remaining.filter((a) => wanted.has(a.id)); + } + const cut = await this.resolveRequestedCut(booking, { - wagonAllocationIds: remaining.map((r) => r.id), + wagonAllocationIds: target.map((r) => r.id), } as RequestWagonCancellationDto); if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut); const edrFault = !!dto.edrFault; const fee = edrFault ? null : await this.priceFee(booking, cut); - const creditAmount = this.creditFor(booking, cut.wagons); + const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, @@ -971,6 +1006,18 @@ export class BookingWagonCancellationService { 'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.', ); } + // Customer-fault fee settles BEFORE the credit is redeemed. An at-loading + // cut applies immediately and opens the credit while its invoice stays + // open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without + // this the customer rebooks the wagons and never pays the cancellation + // fee the notice already promised. EDR fault carries no fee and is + // unaffected; onFeePaid stamps feePaidAt and the gate opens by itself. + if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) { + throw new BadRequestException( + `Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` + + `${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`, + ); + } const source = await this.bookingsRepository.findById(row.bookingId); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source.contractId) { @@ -988,6 +1035,9 @@ export class BookingWagonCancellationService { let partner: Booking | null = null; if (oddFt20) { createDto.skipAutoConsolidation = true; + // An odd credit always leaves a half-empty wagon, so GL names who fills + // it. The candidate list is wide enough (any unpaired, unspent booking on + // the day) that a partner is expected to exist. 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).', @@ -998,6 +1048,15 @@ export class BookingWagonCancellationService { dto.partnerBookingId, dto.scheduledDate, ); + // A dead partner cannot be paid where it stands — its cargo moves to a + // fresh booking that can carry its own invoice and pay window. + if (['EXPIRED', 'CANCELLED'].includes(partner.status)) { + partner = await this.cloneDeadPartner( + partner, + dto.scheduledDate, + userId, + ); + } } const created = await this.contractBooking.createUnderContract( source.contractId, @@ -1032,6 +1091,15 @@ export class BookingWagonCancellationService { ); } if (partner) { + // Corrections GL made to the partner's own containers while pairing — + // scoped to that booking by the repository, so a stray id cannot touch + // another booking's cargo. + if (dto.partnerUnits?.length) { + await this.bookingsRepository.patchContainerUnitsForBooking( + partner.id, + dto.partnerUnits, + ); + } // 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. @@ -1083,6 +1151,13 @@ export class BookingWagonCancellationService { status: string; scheduledDate: string | null; ft20Quantity: number; + units: Array<{ + id: string; + containerSize: string; + containerNumber: string; + sealNumber: string | null; + vgmTons: number; + }>; }> > { const row = await this.mustFind(cancellationId); @@ -1103,6 +1178,18 @@ export class BookingWagonCancellationService { ft20Quantity: (b.bookingContainers ?? []) .filter((line) => Number(line.containerType?.sizeFt) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0), + // Editable while pairing — GL corrects these on the rebook form. + units: (b.bookingContainers ?? []).flatMap((line) => + (line.units ?? []).map((u) => ({ + id: u.id, + containerSize: line.containerType?.sizeFt + ? `${line.containerType.sizeFt}ft` + : '', + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons ?? 0), + })), + ), })); } @@ -1121,11 +1208,29 @@ export class BookingWagonCancellationService { `Booking ${partner.reference} already shares a wagon with another booking.`, ); } - if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) { + // Mirrors findRebookConsolidationCandidates: a partner need not be a live + // committed shipment. One that lost its slot or was called off still has + // cargo to move, and the rebooked wagon is how it moves. + if ( + ![ + 'SUBMITTED', + 'PENDING_CONSOLIDATION', + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + 'EXPIRED', + 'CANCELLED', + ].includes(partner.status) + ) { throw new BadRequestException( `Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`, ); } + // A booking whose own credit was already rebooked elsewhere is spent. + if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) { + throw new BadRequestException( + `Booking ${partner.reference} has already been rebooked from its cancellation credit.`, + ); + } if ( partner.originYardId !== source.originYardId || partner.destinationYardId !== source.destinationYardId || @@ -1153,6 +1258,74 @@ export class BookingWagonCancellationService { return partner; } + /** + * A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no + * longer be paid: its pay window is gone and finalizing it issues nothing a + * customer can settle, so pairing the PAID rebook with it strands the shared + * wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook, + * no payment_deadline — neither half could ever board). So the cargo is + * cloned into a fresh booking under the same contract, which finalizes + * normally into its own invoice and pay window; the dead booking stays dead. + */ + private async cloneDeadPartner( + partner: Booking, + scheduledDate: string, + userId?: string, + ): Promise { + if (!partner.contractId) { + throw new BadRequestException( + `Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`, + ); + } + const dto: CreateBookingUnderContractDto = { + scheduledDate, + paymentCurrency: partner.paymentCurrency ?? undefined, + // GL already chose this pairing — the auto-matcher must not re-home the + // clone behind their back (same reasoning as the rebooked side). + skipAutoConsolidation: true, + containers: (partner.bookingContainers ?? []).map((line) => { + const units = line.units ?? []; + return { + containerSize: line.containerSize ?? undefined, + quantity: Number(line.quantity), + units: units.map((u) => ({ + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? '', + vgmTons: u.vgmTons, + isHazardous: u.isHazardous, + isReefer: u.isReefer, + })), + hazardousQuantity: Number(line.hazardousQuantity ?? 0), + reeferQuantity: Number(line.reeferQuantity ?? 0), + }; + }) as CreateBookingUnderContractDto['containers'], + }; + const created = await this.contractBooking.createUnderContract( + partner.contractId, + dto, + { id: userId ?? partner.createdByUserId ?? undefined }, + { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, + // The dead partner's own contract may have lapsed while it sat expired; + // its cargo is still the cargo GL picked to fill the shared wagon. + { allowExpiredContract: true }, + ); + const clone = await this.bookingsRepository.findByIdWithFiles( + created.booking.id, + ); + if (!clone) { + throw new NotFoundException( + `Replacement booking for ${partner.reference} could not be loaded.`, + ); + } + this.notifyCustomer( + partner, + 'Replacement booking created', + `${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`, + clone.id, + ); + return clone; + } + /** * Link the rebooked (already PAID) booking with the GL-picked partner. A * parked partner is resumed the way pairConsolidation would resume it — @@ -1253,7 +1426,7 @@ export class BookingWagonCancellationService { booking: Booking, dto: RequestWagonCancellationDto, ): Promise { - const totalWagons = Number(booking.wagonsRequired ?? 0); + const totalWagons = await this.wagonFootprint(booking); if (totalWagons <= 0) { throw new BadRequestException('This booking has no wagon requirement to cancel from.'); } @@ -1335,6 +1508,7 @@ export class BookingWagonCancellationService { weightTons: weightShare, // Bookings without unit records fall back to the T2 LIFO trim. quantities: { bySize, ...(units.length === requested ? { units } : {}) }, + totalWagons, }; } @@ -1360,7 +1534,7 @@ export class BookingWagonCancellationService { if (tons <= 0) { throw new BadRequestException('The requested cut is too small to release cargo.'); } - return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; + return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons }; } /** @@ -1416,6 +1590,7 @@ export class BookingWagonCancellationService { wagons, weightTons: tons, quantities: { bulkTons: tons, allocationIds }, + totalWagons, }; } @@ -1460,12 +1635,54 @@ export class BookingWagonCancellationService { wagons, weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)), quantities: { bySize, units, allocationIds }, + totalWagons, }; } + /** + * The booking's wagon footprint for cancellation pricing. + * + * `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so + * a paid booking pulled off a train read 0 wagons and could not be cancelled + * at all. `cancellationWagons` is stamped once at first allocation and never + * cleared — read it first. A booking never allocated has neither, so size it + * from the cargo the same way the scheduler would: TEU geometry for + * containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage + * ÷ wagon capacity for PER_TON bulk. + */ + private async wagonFootprint(booking: Booking): Promise { + const pinned = Number(booking.cancellationWagons ?? 0); + if (pinned > 0) return round2(pinned); + const stored = Number(booking.wagonsRequired ?? 0); + if (stored > 0) return round2(stored); + + const requested = requestedBulkWagons(booking); + if (requested > 0) return requested; + + // Cargo relations drive the sizing — reload when the caller passed a bare + // booking (findById does not always hydrate them). + const full = + booking.bookingContainers || booking.cargoType + ? booking + : ((await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.id }, + relations: { + bookingContainers: { containerType: true }, + cargoType: { wagonTypes: true }, + }, + })) ?? booking); + const capacities = (full.cargoType?.wagonTypes ?? []) + .map((wt) => Number(wt.capacityTons)) + .filter((c) => c > 0); + const bulkCapacity = + full.freightType === 'BULK' && capacities.length + ? Math.max(...capacities) + : undefined; + return round2(wagonsRequiredForBooking(full, bulkCapacity)); + } + /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ - private creditFor(booking: Booking, wagons: number): number { - const totalWagons = Number(booking.wagonsRequired ?? 0); + private creditFor(booking: Booking, wagons: number, totalWagons: number): number { if (totalWagons <= 0) return 0; return round2(Number(booking.totalAmount) * (wagons / totalWagons)); } @@ -1759,6 +1976,7 @@ export class BookingWagonCancellationService { .getRepository(WagonAllocationContainerItem) .delete(cut.map((i) => i.id)); if (cut.length === items.length) { + await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking'); await manager.getRepository(WagonBookingAllocation).delete(alloc.id); } else { const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0); @@ -1805,9 +2023,66 @@ export class BookingWagonCancellationService { await manager .getRepository(WagonAllocationBulkLoad) .delete({ wagonBookingAllocationId: In(ids) }); + await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking'); await manager.getRepository(WagonBookingAllocation).delete(ids); } + /** + * BOOKING_CANCELLED history row for every physical wagon behind the released + * allocations — resolved through the slot BEFORE the allocation rows go, one + * query for the whole batch. Slots with no wagon pinned yet leave no row. + */ + private async recordAllocationRelease( + manager: EntityManager, + allocationIds: string[], + bookingId: string, + reason: string, + ): Promise { + if (!allocationIds.length) return; + const rows: Array<{ + allocationId: string; + wagonId: string; + wagonNumber: string; + yardId: string | null; + trainId: string | null; + scheduleId: string | null; + weightTons: string | null; + loadType: string | null; + }> = await manager.query( + `SELECT a.id AS "allocationId", + w.id AS "wagonId", + w.wagon_number AS "wagonNumber", + w.current_yard_id AS "yardId", + w.train_id AS "trainId", + w.current_train_schedule_id AS "scheduleId", + a.allocated_weight_tons AS "weightTons", + a.load_type AS "loadType" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE a.id = ANY($1::uuid[])`, + [allocationIds], + ); + await this.wagonHistory.record( + manager, + rows.map((r) => ({ + wagonId: r.wagonId, + wagonNumber: r.wagonNumber, + type: WagonEventType.BookingCancelled, + fromYardId: r.yardId, + trainId: r.trainId, + trainScheduleId: r.scheduleId, + bookingId, + reason, + metadata: { + allocationId: r.allocationId, + loadType: r.loadType, + weightTons: r.weightTons == null ? null : Number(r.weightTons), + }, + })), + ); + } + /** Pre-reduction quantities snapshot (only when the booking was never split before). */ private async currentQuantities( manager: EntityManager, 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 9655a9603..66958bc38 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -6,6 +6,7 @@ import { ForbiddenException, Get, HttpCode, + NotFoundException, Param, ParseUUIDPipe, Patch, @@ -86,6 +87,7 @@ import { import { ContractViewDto } from "./dto/contract-view.dto"; import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto"; import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto"; +import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto"; import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto"; import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto"; import { CustomerTruckService } from "./customer-truck.service"; @@ -597,6 +599,34 @@ export class BookingsController { return this.bookingsService.wagonAllocations(id); } + @Get(":id/wagons/export") + @MixedAudience(FREIGHT_PERMS.bookings.view) + @ApiOperation({ + summary: + "Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)", + }) + async wagonAllocationsExport( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + const { filename, buffer } = + await this.bookingsService.wagonAllocationsWorkbook(id); + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.send(buffer); + } + // ── Partial wagon cancellation (paid bookings) ──────────────────────────── // Customer endpoints are ownership-scoped (no portal permission keys); the // staff history/void/rebook variants are permission-gated below. @@ -664,9 +694,11 @@ export class BookingsController { @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); + // GL (createBooking) rebooks credits and must see the ledger for that. const staff = hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || - hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); + hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking); if (!staff) { await this.bookingsService.assertCustomerCanAccessBooking( user?.id, @@ -783,12 +815,78 @@ export class BookingsController { } /** Owner-or-staff gate shared by the per-cancellation actions. */ + /** + * Scope a clearance READ that a transit agent may be making. + * + * Transit agents are portal accounts holding no permission and belonging to + * no company, so the audience guards admit them but the usual company-based + * ownership check would 404 every booking. This narrows them to the shipments + * assigned to them and leaves every other caller — staff and owning customers + * — on the path they already had. Purely widening: nothing that passed before + * starts failing here. + */ + private async assertTransitAgentScope( + bookingId: string, + user: TCurrentUser, + ): Promise { + const userId = user?.id; + if (!userId) return; + if (!(await this.bookingsService.isTransitAgent(userId))) return; + if ( + !(await this.bookingsService.isTransitAgentForBooking(userId, bookingId)) + ) { + // Hidden behind a NotFound so booking ids stay unprobeable, matching the + // customer-ownership failure mode. + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + + /** + * Gate a formerly staff-only clearance route that is now MixedAudience. + * + * Staff still pass on their permission. A portal caller must be a transit + * agent assigned to THIS booking — an ordinary customer is rejected, because + * relaxing the guard must not hand the whole customer base a route that was + * previously staff-only. + * + * Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the + * assigned agent files them in the desk's place, and the assignment is the + * only thing standing between a portal token and the customs record. + */ + private async assertPortalClearanceAccess( + bookingId: string, + user: TCurrentUser, + ): Promise { + if ( + hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) + ) { + return; + } + if ( + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + bookingId, + )) + ) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + } + private async assertWagonCancellationActor( cancellationId: string, user: TCurrentUser, staffPermission: string, ): Promise { if (hasFreightPermission(user, staffPermission)) return; + // Rebooking a credit creates a booking under the contract — GL's booking + // creation key covers it even where the dedicated rebook key was never granted. + if ( + staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook && + hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking) + ) { + return; + } const row = await this.wagonCancellationService.findById(cancellationId); const booking = await this.bookingsService.findById(row.bookingId); await this.bookingsService.assertCustomerCanAccessBooking( @@ -848,7 +946,7 @@ export class BookingsController { }) async bulkAddCustomerTrucks( @Param("id", ParseUUIDPipe) id: string, - @Body() payload: { trucks: AddCustomerTruckDto[] }, + @Body() payload: BulkCustomerTrucksDto, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -1128,6 +1226,9 @@ export class BookingsController { } @Get(":id/clearance") + // A transit agent is a portal account, so MixedAudience admits them without a + // permission; `assertTransitAgentScope` below narrows them to the shipments + // actually assigned to them. @MixedAudience([ FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, @@ -1136,7 +1237,11 @@ export class BookingsController { summary: "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param("id", ParseUUIDPipe) id: string) { + async getClearance( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertTransitAgentScope(id, user); return this.transitionService.getClearanceView(id); } @@ -1278,8 +1383,11 @@ export class BookingsController { return { success: true }; } + // Was staff-only. Opened to the transit agent assigned to the shipment, who + // needs the clearance trail for the bookings they handle; every other portal + // account is still rejected by the scope check below. @Get(":id/clearance/history") - @BookingStaff([ + @MixedAudience([ FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDjActions, ]) @@ -1287,7 +1395,11 @@ export class BookingsController { summary: "Clearance action history for the booking — reviews, workflow steps, charges (newest first)", }) - getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) { + async getClearanceHistory( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); return this.clearanceEventService.list(id); } @@ -1310,6 +1422,12 @@ export class BookingsController { hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions); if (isStaff) return this.clearanceChargeService.list(id); + // The transit agent handling this shipment sees the same customer-facing + // slice the customer does — charges actually sent, never the internal + // draft/billing view `list()` returns. + if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) { + return this.clearanceChargeService.listForCustomer(id); + } const booking = await this.bookingsService.findById(id); await this.bookingsService.assertCustomerCanAccessBooking( user?.id, @@ -1777,8 +1895,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + // Djibouti-desk write, also filed by the transit agent assigned to this + // shipment — `assertPortalClearanceAccess` rejects every other portal caller. @Post(":id/clearance/delivery-order") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") async uploadBookingDeliveryOrder( @@ -1788,6 +1908,7 @@ export class BookingsController { @Body("doCollectedDate") doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { + await this.assertPortalClearanceAccess(id, user); const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, files ?? [], @@ -1798,7 +1919,7 @@ export class BookingsController { } @Post(":id/clearance/release-order") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes("multipart/form-data") async uploadBookingReleaseOrder( @@ -1807,6 +1928,7 @@ export class BookingsController { @Body("vesselDepartureDate") vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { + await this.assertPortalClearanceAccess(id, user); const result = await this.bookingClearanceService.uploadReleaseOrder( id, files ?? [], @@ -1820,13 +1942,69 @@ export class BookingsController { }; } + // Transit-agent arrival paperwork (export): gate pass and Djibouti T1 sets. + // Same audience rule as the DO/RO uploads above — the desk, or the agent + // assigned to this shipment. + @Post(":id/clearance/gate-pass-documents") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + async uploadBookingGatePassDocuments( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + return this.bookingClearanceService.uploadTransitArrivalDocuments( + id, + "gate_pass", + files ?? [], + resolveAuthUserId(user), + ); + } + + @Post(":id/clearance/djibouti-t1-documents") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + async uploadBookingDjiboutiT1Documents( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + return this.bookingClearanceService.uploadTransitArrivalDocuments( + id, + "djibouti_t1", + files ?? [], + resolveAuthUserId(user), + ); + } + + @Delete(":id/clearance/transit-documents/:fileId") + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) + @HttpCode(204) + async removeBookingTransitDocument( + @Param("id", ParseUUIDPipe) id: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertPortalClearanceAccess(id, user); + await this.bookingClearanceService.removeTransitArrivalDocument( + id, + fileId, + resolveAuthUserId(user), + ); + } + @Post(":id/clearance/ro-amendment") - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) async requestBookingRoAmendment( @Param("id", ParseUUIDPipe) id: string, @Body() dto: RoAmendmentDto, @CurrentUser() user: TCurrentUser, ) { + await this.assertPortalClearanceAccess(id, user); const booking = await this.bookingClearanceService.requestRoAmendment( id, dto.note, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index e119af52e..6146da711 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; +import { ExportsModule } from '../exports/exports.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; @@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; // CustomersModule, RuleEngineModule, FileUploadSettingsModule, + ExportsModule, SignaturesModule, registerExchangeModule(), ], 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 5e0e3c994..21428c77d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -34,10 +34,12 @@ import { DocumentReviewStatus, } from './entities/booking-document-review.entity'; import { BookingContainer } from './entities/booking-container.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { Booking } from './entities/booking.entity'; import { BookingContractSignature, @@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository { * Bookings a GL operator may manually link to `booking` as its odd-20ft * consolidation partner (Path B customs flow). Unlike * {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact - * quantity complement — this lists CANDIDATES for a human to choose from, so - * the filter is deliberately looser: any other customs booking on the same - * route/direction that is itself carrying an odd 20ft count. Two odd counts - * always sum to even, so any pick fills the shared wagon. + * quantity complement — this lists CANDIDATES for a human to choose from, but + * every row must still be a legal pick: another customs booking on the same + * route/direction, riding the same booking day, that is itself carrying an odd + * 20ft count. Two odd counts always sum to even, so any pick fills the shared + * wagon. * - * Bare instances awaiting completion have no persisted containers yet, so the - * odd-count test runs on the requested container lines when they exist and the - * booking is offered as a candidate when they do not (GL enters its cargo on - * the split form). + * A booking whose cargo is not entered yet is NOT a candidate: with no + * container lines its 20ft count is unknown, so pairing with it cannot be + * shown to fill the wagon. Same rule as + * {@link findRebookConsolidationCandidates}. */ async findManualConsolidationCandidates( booking: Booking, limit = 50, ): Promise { - const rows = await this.repository + const qb = this.repository .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') @@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository { 'OPERATION_CHANGES_REQUESTED', 'PENDING_CONSOLIDATION', ], - }) - .orderBy('b.createdAt', 'ASC') - .take(limit) - .getMany(); + }); - // Odd-20ft test in memory: a bare instance has no containers yet (GL fills - // them on the split form) and stays a candidate; one that already carries - // cargo qualifies only when its 20ft total is odd. + // Same EAT booking day — the pair shares one physical wagon, so it must + // board one train. Applied only when this booking has a date of its own; + // without one there is no day to match against and route/direction stand + // alone, mirroring findComplementaryConsolidationPartner. + if (booking.scheduledDate) { + qb.andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: booking.scheduledDate }, + ); + } + + const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany(); + + // Odd-20ft test in memory. A booking with no container lines has an unknown + // 20ft count, so it cannot be shown to complete the wagon and is not + // offered. return rows.filter((row) => { const lines = row.bookingContainers ?? []; - if (lines.length === 0) return true; + 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); @@ -396,10 +409,16 @@ 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. + * odd-20ft bookings on the same route/direction riding the requested day. * Unlike {@link findManualConsolidationCandidates} this is not customs-only: * GL picks who shares the rebooked wagon whatever the contract kind. + * + * The status set is deliberately wide. A partner here is not required to be a + * live, committed shipment — a booking that lost its slot (EXPIRED) or was + * cancelled still has cargo that GL can put back on a train, and pairing it + * with the rebooked credit is how both halves get moving again. What it must + * not be is already spoken for: a booking whose own cancellation credit has + * been rebooked elsewhere is excluded, as is one already paired. */ async findRebookConsolidationCandidates( booking: Booking, @@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository { .createQueryBuilder('b') .leftJoinAndSelect('b.bookingContainers', 'bc') .leftJoinAndSelect('bc.containerType', 'ct') + // Units come back so GL can correct the partner's container numbers, + // seals and VGMs while pairing. + .leftJoinAndSelect('bc.units', 'unit') .leftJoinAndSelect('b.company', 'company') .where('b.id != :bookingId', { bookingId: booking.id }) .andWhere('b.consolidationPartnerId IS NULL') @@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository { tradeDirection: booking.tradeDirection, }) .andWhere('b.status IN (:...statuses)', { - statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], + statuses: [ + 'SUBMITTED', + 'PENDING_CONSOLIDATION', + 'CLEARANCE_READY', + 'OPERATION_CHANGES_REQUESTED', + // Lost its slot or was called off — its cargo is still real and can + // ride the rebooked wagon. + 'EXPIRED', + 'CANCELLED', + ], }) + // A cancelled booking whose own credit was already spent on a rebook is + // gone — pairing with it would hand the same cargo out twice. + .andWhere( + `NOT EXISTS ( + SELECT 1 FROM freight.booking_wagon_cancellations c + WHERE c.booking_id = b.id + AND c.rebooked_booking_id IS NOT NULL + AND c.deleted_at IS NULL + )`, + ) // Same EAT booking day as the rebook — the pair shares one physical // wagon, so it must board one train. .andWhere( @@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository { return new Set(rows.map((r) => r.bookingId)); } + /** + * Bookings among `bookingIds` that hold a redeemable wagon-cancellation + * credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth + * something, and it has not been spent on a rebook yet. Surfaced on the GL + * clearance queue so a paid-for credit is visibly rebookable from the list + * rather than only from the booking's own page. + */ + async findBookingsWithRedeemableCredit( + bookingIds: string[], + ): Promise> { + if (bookingIds.length === 0) return new Map(); + const rows = (await this.dataSource + .getRepository(BookingWagonCancellation) + .createQueryBuilder('c') + .select('c.booking_id', 'bookingId') + .addSelect('c.id', 'cancellationId') + .where('c.booking_id IN (:...bookingIds)', { bookingIds }) + .andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' }) + .andWhere('c.credit_amount > 0') + .andWhere('c.rebooked_booking_id IS NULL') + .andWhere('c.deleted_at IS NULL') + .getRawMany()) as Array<{ bookingId: string; cancellationId: string }>; + return new Map(rows.map((r) => [r.bookingId, r.cancellationId])); + } + + /** + * Apply container-unit corrections (number / seal / VGM) to units that belong + * to `bookingId`. The ownership join is the point: a unit id from another + * booking silently matches nothing rather than editing a stranger's cargo. + * Sizes and quantities are never touched — only the identifying details. + * Returns how many units were actually updated. + */ + async patchContainerUnitsForBooking( + bookingId: string, + patches: Array<{ + id: string; + containerNumber?: string; + sealNumber?: string; + vgmTons?: number; + }>, + ): Promise { + if (patches.length === 0) return 0; + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + const owned = await unitRepo + .createQueryBuilder('u') + .innerJoin('u.bookingContainer', 'bc') + .where('bc.booking_id = :bookingId', { bookingId }) + .andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) }) + .select('u.id', 'id') + .getRawMany<{ id: string }>(); + const ownedIds = new Set(owned.map((r) => r.id)); + + let updated = 0; + for (const patch of patches) { + if (!ownedIds.has(patch.id)) continue; + const set: Record = {}; + if (patch.containerNumber !== undefined) + set.containerNumber = patch.containerNumber; + if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber; + if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons; + if (Object.keys(set).length === 0) continue; + await unitRepo.update(patch.id, set as never); + updated += 1; + } + return updated; + } + + /** + * Has this booking's own wagon-cancellation credit already been spent on a + * rebook? Such a booking must not be offered or accepted as a consolidation + * partner — its cargo has already moved to the rebooked booking. + */ + async hasSpentCancellationCredit(bookingId: string): Promise { + const count = await this.dataSource + .getRepository(BookingWagonCancellation) + .createQueryBuilder('c') + .where('c.booking_id = :bookingId', { bookingId }) + .andWhere('c.rebooked_booking_id IS NOT NULL') + .andWhere('c.deleted_at IS NULL') + .getCount(); + return count > 0; + } + findDocumentReview( bookingId: string, settingCode: string, @@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository { select: { bookingId: true, trainScheduleId: true }, }); const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId])); + + // The allocated train's own departure date — distinct from the customer's + // requested `booking.scheduledDate`. The list column shows this once a + // booking is on a train, so fetch it alongside the link ids. + const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[]; + const schedules = scheduleIds.length + ? await this.dataSource.getRepository(TrainSchedule).find({ + where: { id: In(scheduleIds) }, + select: { + id: true, + reference: true, + trainNumber: true, + status: true, + scheduledDepartureDate: true, + }, + }) + : []; + const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule])); + for (const item of items) { - (item as Booking & { trainScheduleId?: string | null }).trainScheduleId = - scheduleByBooking.get(item.id) ?? null; + const scheduleId = scheduleByBooking.get(item.id) ?? null; + const enriched = item as Booking & { + trainScheduleId?: string | null; + trainScheduleReference?: string | null; + trainScheduleDepartureDate?: string | null; + }; + enriched.trainScheduleId = scheduleId; + const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined; + enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null; + enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate + ? new Date(schedule.scheduledDepartureDate).toISOString() + : null; } } @@ -1786,6 +1939,7 @@ export class BookingsRepository extends BaseRepository { Booking, | 'schedulingStatus' | 'wagonsRequired' + | 'cancellationWagons' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt' diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 71107650f..ea901bb16 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference, logCtx } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { TabularExportService } from '../exports/tabular-export.service'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -29,6 +30,11 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; +import { + EDR_HAULAGE_CONFLICT_MESSAGE, + LAST_MILE_COMMITTED_SQL, + edrHaulsThisBooking, +} from '../../common/mile-haulage.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -99,6 +105,38 @@ export interface PaginatedBookings { }; } +/** One container on an allocated wagon (raw SQL json_agg projection). */ +export interface WagonAllocationContainer { + containerNumber: string | null; + sealNumber: string | null; + positionOnWagon: number | null; + grossWeightTons: number | null; + sizeFt: number | null; +} + +/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */ +export interface WagonAllocationRow { + allocationId: string; + sequenceNo: number | null; + wagonNumber: string | null; + wagonType: string | null; + wagonTypeCode: string | null; + /** numeric columns arrive as strings from pg. */ + tareWeightTons: string | null; + capacityTons: string | null; + lengthMeters: string | null; + allocatedWeightTons: string | null; + loadType: string | null; + status: string | null; + trainNumber: string | null; + departureAt: string | Date | null; + originStation: string | null; + destinationStation: string | null; + bulkCargoDescription: string | null; + bulkQuantity: string | null; + containers: WagonAllocationContainer[]; +} + /** One wagon line on the carriage acceptance sheet (raw SQL projection). */ interface CarriageAcceptanceWagonRow { sequenceNo: number; @@ -112,8 +150,13 @@ interface CarriageAcceptanceWagonRow { departureAt: Date | null; marshalledAt: string | null; arrivalAt: string | null; + /** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */ + departureStation: string | null; + arrivalStation: string | null; containerNumbers: string | null; sealNumbers: string | null; + /** Allocation status — LOADED/DEPARTED means EDR has the cargo. */ + status: string | null; } /** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ @@ -162,6 +205,7 @@ export class BookingsService { private readonly bookingContractService: BookingContractService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly tabularExport: TabularExportService, ) {} async assignCustomerTruck( @@ -169,18 +213,23 @@ export class BookingsService { dto: CustomerTruckAssignmentDto, ): Promise { const booking = await this.findById(bookingId); - const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); - const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); - const usesMileService = - booking.tradeDirection === 'IMPORT' - ? hasLastMile - : booking.tradeDirection === 'EXPORT' - ? hasFirstMile - : hasFirstMile || hasLastMile; - if (usesMileService) { - throw new BadRequestException( - 'Customer truck assignment is only allowed when first/last mile delivery is not selected', - ); + // Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg + // closes self-haul only once it has been approved. + const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query( + `SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted" + FROM freight.bookings b + WHERE b.id = $1`, + [bookingId], + ); + if ( + edrHaulsThisBooking({ + tradeDirection: booking.tradeDirection ?? null, + firstMile: booking.firstMilePickupAddress ?? null, + lastMile: booking.lastMileDeliveryAddress ?? null, + lastMileCommitted: Boolean(commitment?.lastMileCommitted), + }) + ) { + throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.customerTruckAssignedAt) { throw new ConflictException('Customer truck assignment is already submitted and locked'); @@ -263,9 +312,13 @@ export class BookingsService { /** * Carriage acceptance sheet — one per booking, listing every wagon the booking - * occupies. Handed to the customer when EDR accepts the cargo (export) and when - * the wagons are allocated before marshalling (import), so it is only available - * once the booking has wagon allocations. + * occupies. A booking is routinely loaded in parts (some containers go, the + * rest wait for the next train), so each row carries a Status of Loaded or + * Not loaded and the totals count only the loaded ones: the customer sees the + * whole plan on one page without the sheet overstating what EDR has taken. + * + * Handed to the customer when EDR accepts the cargo (export) and when the + * wagons are allocated before marshalling (import). */ async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); @@ -285,6 +338,9 @@ export class BookingsService { s.scheduled_departure_date AS "departureAt", so.label AS "marshalledAt", sd.label AS "arrivalAt", + COALESCE(by_.label, so.label) AS "departureStation", + COALESCE(ay.label, sd.label) AS "arrivalStation", + a.status AS "status", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" FROM freight.wagon_booking_allocations a @@ -296,13 +352,31 @@ export class BookingsService { ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id + LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + AND ( + $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS ( + SELECT 1 + FROM freight.booking_container_units received_unit + JOIN freight.booking_container received_line + ON received_line.id = received_unit.booking_container_id + AND received_line.deleted_at IS NULL + WHERE received_line.booking_id = a.booking_id + AND received_unit.container_number = ci.container_number + AND received_unit.received_to_port = true + AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL + AND received_unit.deleted_at IS NULL + ) + ) WHERE a.booking_id = $1 AND a.deleted_at IS NULL - GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, - s.train_number, s.scheduled_departure_date, so.label, sd.label + GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label, + by_.label, ay.label + HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0 ORDER BY tsw.sequence_no`, - [bookingId], + [bookingId, booking.tradeDirection, booking.freightType], ); // Export acceptance happens at the warehouse gate, not at marshalling: EDR // takes custody of the cargo when it receives it, and the customer is handed @@ -337,17 +411,17 @@ export class BookingsService { ) : booking.tradeDirection === 'EXPORT' ? await this.dataSource.query( - `SELECT inv.weight AS "allocatedWeightTons", - c.container_number AS "containerNumbers" - FROM freight.warehouse_inventory inv - LEFT JOIN freight.containers c - ON c.id = inv.container_id AND c.deleted_at IS NULL - WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL - AND COALESCE( - NULLIF(TRIM(inv.grn_number), ''), - substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') - ) IS NOT NULL - ORDER BY inv.created_at`, + `SELECT unit.vgm_tons AS "allocatedWeightTons", + unit.container_number AS "containerNumbers", + unit.seal_number AS "sealNumbers" + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = $1 + AND unit.deleted_at IS NULL + AND unit.received_to_port = true + AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL + ORDER BY unit.received_at, unit.container_number`, [bookingId], ) : []; @@ -381,8 +455,12 @@ export class BookingsService { departureAt: null, marshalledAt: null, arrivalAt: null, + departureStation: null, + arrivalStation: null, containerNumbers: row.containerNumbers, sealNumbers: row.sealNumbers ?? null, + // A received line has no allocation; it is cargo EDR already holds. + status: null, })); } @@ -403,7 +481,7 @@ export class BookingsService { * an array per wagon, bulk load description when the wagon carries bulk). * Empty array until the booking has been allocated onto a train. */ - async wagonAllocations(bookingId: string): Promise { + async wagonAllocations(bookingId: string): Promise { return this.dataSource.query( `SELECT a.id AS "allocationId", tsw.sequence_no AS "sequenceNo", @@ -457,6 +535,103 @@ export class BookingsService { ); } + /** + * The Wagons tab's Excel export: the booking's customer identity in the KPI + * header, then one row per allocated wagon. + * + * Container numbers are flattened into a single cell rather than exploded + * into one row per container — the sheet is a wagon manifest, and a reader + * counting rows must get the wagon count. + */ + async wagonAllocationsWorkbook( + bookingId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + const wagons = await this.wagonAllocations(bookingId); + + // Same precedence the booking list uses: a shipping line owns its bookings + // directly, a government booking names its institution, everyone else is + // the customer company. + // `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies), + // not a declared relation on the entity — hence the cast, matching that helper. + const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } }) + .shippingLineCompany; + const customerName = + shippingLine?.name ?? + (booking.isGovernment ? booking.governmentInstitution : null) ?? + booking.company?.name ?? + '—'; + + const rows = wagons.map((w) => ({ + sequenceNo: w.sequenceNo, + wagonNumber: w.wagonNumber ?? '—', + wagonType: w.wagonType ?? '—', + loadType: w.loadType ?? '—', + status: w.status ?? '—', + tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons), + capacityTons: w.capacityTons === null ? null : Number(w.capacityTons), + allocatedWeightTons: + w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons), + lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters), + containerCount: w.containers?.length ?? 0, + containerNumbers: + (w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—', + sealNumbers: + (w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—', + bulkCargo: w.bulkCargoDescription ?? '—', + bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity), + trainNumber: w.trainNumber ?? '—', + departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—', + originStation: w.originStation ?? '—', + destinationStation: w.destinationStation ?? '—', + // Repeated on every row so the sheet survives being filtered, sorted or + // pasted into a combined workbook, where the header block is lost. + customerName, + bookingReference: booking.reference, + })); + + const totalAllocated = rows.reduce( + (sum, r) => sum + (r.allocatedWeightTons ?? 0), + 0, + ); + + const buffer = await this.tabularExport.toXlsx({ + title: `Wagons ${booking.reference}`.slice(0, 31), + description: `Wagons allocated to booking ${booking.reference} — ${customerName}`, + label: 'booking:wagon-allocations', + kpis: [ + { label: 'Wagons', value: rows.length }, + { label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) }, + { label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' }, + ], + columns: [ + { key: 'bookingReference', label: 'Booking', type: 'string' }, + { key: 'customerName', label: 'Customer', type: 'string' }, + { key: 'sequenceNo', label: 'Seq', type: 'number' }, + { key: 'wagonNumber', label: 'Wagon number', type: 'string' }, + { key: 'wagonType', label: 'Wagon type', type: 'string' }, + { key: 'loadType', label: 'Load type', type: 'string' }, + { key: 'status', label: 'Status', type: 'string' }, + { key: 'tareWeightTons', label: 'Tare', type: 'tons' }, + { key: 'capacityTons', label: 'Capacity', type: 'tons' }, + { key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' }, + { key: 'lengthMeters', label: 'Length (m)', type: 'number' }, + { key: 'containerCount', label: 'Containers', type: 'number' }, + { key: 'containerNumbers', label: 'Container numbers', type: 'string' }, + { key: 'sealNumbers', label: 'Seal numbers', type: 'string' }, + { key: 'bulkCargo', label: 'Bulk cargo', type: 'string' }, + { key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' }, + { key: 'trainNumber', label: 'Train', type: 'string' }, + { key: 'departureAt', label: 'Departure', type: 'date' }, + { key: 'originStation', label: 'Origin', type: 'string' }, + { key: 'destinationStation', label: 'Destination', type: 'string' }, + ], + rows, + }); + + return { filename: `wagons-${booking.reference}.xlsx`, buffer }; + } + /** * Split the booking amount across its wagons, proportional to allocated weight * (equal shares when no weights are recorded). The last row absorbs the rounding @@ -497,7 +672,17 @@ export class BookingsService { const header = wagons[0]; const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); - const totals = wagons.reduce( + // Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the + // totals count only those: the sheet shows the whole plan, but must never + // total up cargo still sitting in the yard. A received-line sheet + // (pendingWagons) has no allocation status, and every line on it is cargo + // already accepted, so it counts in full. + const isLoaded = (w: CarriageAcceptanceWagonRow) => + pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED'; + const loadedWagons = wagons.filter(isLoaded); + const notLoadedCount = wagons.length - loadedWagons.length; + + const totals = loadedWagons.reduce( (acc, w) => ({ tare: acc.tare + (Number(w.tareWeightTons) || 0), capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), @@ -507,7 +692,7 @@ export class BookingsService { { tare: 0, capacity: 0, load: 0, length: 0 }, ); // A wagon carrying no weight and no container is running empty under this booking. - const fullWagons = wagons.filter( + const fullWagons = loadedWagons.filter( (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), ).length; @@ -520,11 +705,14 @@ export class BookingsService { ${num(w.tareWeightTons, 2)} ${num(w.equatedLength)} ${num(w.loadCapacityTons)} - ${esc(arrivalStation)} + ${esc(w.arrivalStation ?? arrivalStation)} ${esc(cargoName)} - ${esc(departureStation)} + ${esc(w.departureStation ?? departureStation)} ${esc(w.containerNumbers)} ${esc(w.sealNumbers)} + ${ + pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded' + } ${money(prices[i])} `, ) @@ -535,23 +723,37 @@ export class BookingsService { // figure from the printed sheet. const totalsRow = ` TOT - ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} - ${ - pendingWagons - ? 'pending marshalling' - : `full ${fullWagons} / empty ${wagons.length - fullWagons}` - } + ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} + ${num(totals.tare, 2)} ${num(totals.length)} ${num(totals.capacity)} - Gross ${num(totals.tare + totals.load)} T + + ${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''} ${money(totalAmount)} `; + // The signed footer of the paper sheet. Rendered as .tile so the + // Chromium-less fallback (buildTabularFallbackPdf parses .tile, not + // arbitrary divs) still prints every figure. + const footer = ` + `; + return ` @@ -568,6 +770,8 @@ export class BookingsService { .meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; } .meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; } .summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; } + .footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; } + .footer-summary .tile { background: #f8fafc; } .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; } .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; } .tile strong { font-size: 11px; } @@ -575,6 +779,8 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } + .loaded { color: #0f766e; font-weight: 700; } + .pending { color: #b45309; font-weight: 700; } tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } @@ -618,6 +824,7 @@ export class BookingsService { Departure Station Container No. Seal No. + Status Price (${esc(currency)}) @@ -626,6 +833,7 @@ export class BookingsService { ${totalsRow} +${footer}
${ @@ -1988,6 +2196,65 @@ export class BookingsService { } } + /** + * True when `userId` is a transit agent currently assigned to this booking. + * + * Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that + * assertion guards ~29 call sites, including wagon cancellations, rebooking + * and customer-truck writes. A transit agent must reach the clearance READS + * for the shipments they handle and nothing else, so the two ownership rules + * stay separate and each caller opts in explicitly. + * + * Queried directly rather than through TransitAssignmentsService: that module + * imports BookingsModule, so injecting it here would close an import cycle. + */ + /** Is this portal account a transit agent at all? */ + async isTransitAgent(userId: string | undefined): Promise { + if (!userId) return false; + const rows: { one: number }[] = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.transit_agents a + WHERE a.user_id = $1 AND a.deleted_at IS NULL + LIMIT 1`, + [userId], + ); + return rows.length > 0; + } + + async isTransitAgentForBooking( + userId: string | undefined, + bookingId: string, + ): Promise { + if (!userId) return false; + const rows: { one: number }[] = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.transit_assignments ta + JOIN freight.transit_agents a ON a.id = ta.transit_agent_id + WHERE a.user_id = $1 + AND ta.booking_id = $2 + AND ta.deleted_at IS NULL + AND a.deleted_at IS NULL + LIMIT 1`, + [userId, bookingId], + ); + return rows.length > 0; + } + + /** + * Authorize a clearance READ on one booking for either audience a portal + * account can be: the owning customer, or a transit agent assigned to it. + * + * Read-only by contract — every caller is a GET. Writes keep using + * {@link assertCustomerCanAccessBooking}, which a transit agent never passes. + */ + async assertCanReadBookingClearance( + userId: string | undefined, + booking: Booking, + ): Promise { + if (await this.isTransitAgentForBooking(userId, booking.id)) return; + await this.assertCustomerCanAccessBooking(userId, booking); + } + /** * Build the customer-facing shipment tracking payload for a booking from the * train schedule it is assigned to and the live checkpoint log. The caller is diff --git a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts index 195e0cab0..45907c09a 100644 --- a/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/carriage-acceptance-price-split.spec.ts @@ -24,3 +24,84 @@ describe('carriage acceptance sheet — price split', () => { expect(shares).toEqual([33.33, 33.33, 33.34]); }); }); + +// The HTML builder only reaches `this` for two prototype helpers (escapeHtml, +// splitAmountAcrossWagons), so the prototype itself serves as `this`. +const buildSheet = (wagons: unknown[], booking: Record = {}): string => + ( + BookingsService.prototype as unknown as { + buildCarriageAcceptanceSheetHtml( + b: unknown, + w: unknown[], + o: { pendingWagons: boolean }, + ): string; + } + ).buildCarriageAcceptanceSheetHtml.call( + BookingsService.prototype, + { + reference: 'BK-1', + tradeDirection: 'EXPORT', + totalAmount: 100, + paymentCurrency: 'ETB', + originYard: { label: 'Booking Origin' }, + destinationYard: { label: 'Booking Destination' }, + ...booking, + }, + wagons, + { pendingWagons: false }, + ); + +const wagon = (over: Record = {}) => ({ + sequenceNo: 1, + wagonType: 'FLAT', + wagonNumber: 'W-001', + tareWeightTons: '20', + equatedLength: '14', + loadCapacityTons: '60', + allocatedWeightTons: '40', + trainNumber: '8302', + departureAt: null, + marshalledAt: 'DCT/SGTD', + arrivalAt: 'GMP', + departureStation: null, + arrivalStation: null, + containerNumbers: 'CN-1', + sealNumbers: 'SL-1', + status: 'LOADED', + ...over, +}); + +describe('carriage acceptance sheet — rows and footer', () => { + it('prints each row its own Departure/Arrival Station, falling back to the booking yards', () => { + const html = buildSheet([ + wagon({ departureStation: 'Dire Dawa Port', arrivalStation: 'Adama' }), + wagon({ sequenceNo: 2, wagonNumber: 'W-002' }), + ]); + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + expect(html).toContain('Booking Origin'); + expect(html).toContain('Booking Destination'); + }); + + it('totals the footer over loaded wagons only', () => { + const html = buildSheet([ + wagon(), + wagon({ sequenceNo: 2, wagonNumber: 'W-002', status: 'ALLOCATED' }), + wagon({ + sequenceNo: 3, + wagonNumber: 'W-003', + allocatedWeightTons: '0', + containerNumbers: null, + }), + ]); + // 2 loaded of 3: tare 40, capacity 120, equated length 28, gross 40 + 40 load. + expect(html).toContain('In Total Wagon No.2'); + expect(html).toContain('Tare Weight (T)40.00'); + expect(html).toContain('Load Capacity (T)120.000'); + expect(html).toContain('Gross Weight (T)80.000'); + expect(html).toContain('Equated Length28.000'); + expect(html).toContain('Full Wagon1'); + expect(html).toContain('Empty Wagon1'); + expect(html).toContain('Total Amount (ETB)100.00'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index 3df681d9c..88df3d80e 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; +import type { + BulkTruckUploadError, + BulkTruckUploadResult, +} from './dto/bulk-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { EDR_HAULAGE_CONFLICT_MESSAGE, - usesEdrMileService, + LAST_MILE_COMMITTED_SQL, + edrHaulsThisBooking, } from '../../common/mile-haulage.util'; import { assertBulkTonnageRemains, @@ -35,6 +40,9 @@ interface BookingGuardRow { lastMile: string | null; paymentStatus: string | null; status: string | null; + trainScheduleStatus: string | null; + /** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */ + lastMileCommitted: boolean; } /** @@ -294,19 +302,16 @@ export class CustomerTruckService { } const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (booking.freightType === 'CONTAINER' && !requested.length) { + throw new BadRequestException('Select the containers loaded on this truck'); + } if (requested.length) { - const bookingNumbers = await this.bookingContainerNumbers(bookingId); - for (const n of requested) { - if (!bookingNumbers.includes(n)) { - throw new BadRequestException(`Container ${n} is not one of this booking's containers`); - } - } - const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId); - for (const n of requested) { - if (elsewhere.includes(n)) { - throw new ConflictException(`Container ${n} is already loaded onto another truck`); - } - } + assertTruckLoad({ + containers: requested, + bookingContainers: await this.bookingContainerNumbers(bookingId), + sizes: await bookingContainerSizes(this.dataSource, bookingId, requested), + assignedElsewhere: await this.assignedContainerNumbersExcept(bookingId, assignmentId), + }); } await this.dataSource.transaction(async (manager) => { @@ -542,9 +547,17 @@ export class CustomerTruckService { first_mile_pickup_address AS "firstMile", last_mile_delivery_address AS "lastMile", payment_status AS "paymentStatus", - status - FROM freight.bookings - WHERE id = $1 AND deleted_at IS NULL`, + b.status, + (SELECT ts.status + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts + ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL + WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL + ORDER BY ts.updated_at DESC + LIMIT 1) AS "trainScheduleStatus", + ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted" + FROM freight.bookings b + WHERE b.id = $1 AND b.deleted_at IS NULL`, [bookingId], ); if (!row) throw new NotFoundException(`Booking ${bookingId} not found`); @@ -552,10 +565,12 @@ export class CustomerTruckService { } private assertSelfHaulPaid(booking: BookingGuardRow): void { - // Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two - // halves of this rule cannot drift apart — they did, and a booking ended up - // with a customer truck and an EDR leg at once. - if (usesEdrMileService(booking)) { + // Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) so the + // two halves of this rule cannot drift apart — they did, and a booking ended + // up with a customer truck and an EDR leg at once. A last-mile leg only + // blocks self-haul once it is approved; until then the customer may still + // bring their own truck, and doing so makes the pending request unapprovable. + if (edrHaulsThisBooking(booking)) { throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE); } if (booking.paymentStatus !== 'PAID') { @@ -575,7 +590,7 @@ export class CustomerTruckService { private assertAssignmentWindow(booking: BookingGuardRow): void { const status = booking.status ?? ''; if (booking.tradeDirection === 'IMPORT') { - if (status !== 'ARRIVED') { + if (status !== 'ARRIVED' && booking.trainScheduleStatus !== 'ARRIVED') { throw new BadRequestException( 'Import pickup trucks can only be assigned after the train has arrived', ); @@ -626,26 +641,29 @@ export class CustomerTruckService { /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + /** + * Add trucks one at a time, keeping the good ones. Partial success is the + * right shape here: one mistyped plate in a twenty-row spreadsheet should not + * discard the other nineteen trucks. Every row still goes through `addTruck`, + * so no guard is skipped. + */ async addBulkTrucks( bookingId: string, dtos: AddCustomerTruckDto[], - ): Promise<{ - success: number; - failed: number; - errors: Array<{ row: number; truck: string; reason: string }>; - }> { - const errors: Array<{ row: number; truck: string; reason: string }> = []; + ): Promise { + const errors: BulkTruckUploadError[] = []; let successCount = 0; for (let i = 0; i < dtos.length; i++) { try { await this.addTruck(bookingId, dtos[i]); successCount++; - } catch (err: any) { + } catch (err) { errors.push({ - row: i + 2, // Row 1 is header + index: i, + row: i + 2, // Row 1 is the header truck: dtos[i].truckPlateNumber, - reason: err.message || 'Unknown error', + reason: err instanceof Error ? err.message : 'Unknown error', }); } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts index 9816b3405..1085f954a 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/add-customer-truck.dto.ts @@ -12,7 +12,7 @@ import { Min, } from 'class-validator'; -import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; +import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types'; /** * Add one external customer truck to a booking. @@ -41,7 +41,7 @@ export class AddCustomerTruckDto { @IsArray() @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts index 5e03c7bc4..e3249e684 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -1,48 +1,41 @@ -import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; -import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; +import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; -export class BulkCustomerTruckRow { - @IsString() - @IsNotEmpty() - truckPlateNumber!: string; - - @IsString() - @IsNotEmpty() - driverName!: string; - - @IsString() - @IsNotEmpty() - @IsIn(CUSTOMER_TRUCK_TYPES) - truckType!: string; - - @IsOptional() - @IsArray() - @ArrayMaxSize(2) - @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { - each: true, - message: 'each container must be ISO format (e.g. ABCD1234567)', - }) - containerNumbers?: (string | null)[]; -} +import { AddCustomerTruckDto } from './add-customer-truck.dto'; +/** + * Bulk self-haul truck assignment, parsed from the customer's Excel upload in + * the browser and posted as JSON (the house pattern — the API never receives an + * .xlsx for import). + * + * Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields: + * the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk + * cargo could not be uploaded at all. + */ export class BulkCustomerTrucksDto { @IsArray() + @ArrayMinSize(1) @ArrayMaxSize(100) - trucks!: BulkCustomerTruckRow[]; + @ValidateNested({ each: true }) + @Type(() => AddCustomerTruckDto) + trucks!: AddCustomerTruckDto[]; +} + +export interface BulkTruckUploadError { + /** + * Position in the submitted array. The client knows which spreadsheet line it + * read each entry from, so it maps this back to the row number the customer + * actually sees. + */ + index: number; + /** 1-based row assuming a single header line — a fallback for non-Excel callers. */ + row: number; + truck: string; + reason: string; } export interface BulkTruckUploadResult { success: number; failed: number; - errors: Array<{ - row: number; - truck: string; - reason: string; - }>; - created: Array<{ - truckPlateNumber: string; - driverName: string; - containers: number; - }>; + errors: BulkTruckUploadError[]; } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts index 9daf7523e..e4655a739 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts @@ -1,12 +1,12 @@ import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types'; -export const CUSTOMER_TRUCK_TYPES = [ - 'Flatbed', - 'Container Chassis', - 'Lowboy', - 'Box Truck', - 'Tipper', -] as const; +/** + * Re-exported for the DTOs that already import it from here. The list itself + * lives in `@edr/types` so the portal's dropdown and its Excel template read the + * same values this validator enforces. + */ +export { CUSTOMER_TRUCK_TYPES }; export class CustomerTruckAssignmentDto { @IsString() @@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto { @IsString() @IsNotEmpty() @MaxLength(16) - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567', }) containerNumberToLoad!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts index 31ab1b5bd..d3e73267d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/depart-customer-truck.dto.ts @@ -8,6 +8,7 @@ import { Matches, Min, } from 'class-validator'; +import { ISO_CONTAINER_NUMBER } from '@edr/types'; /** * Register an import self-haul truck leaving the port: the containers it actually @@ -20,7 +21,7 @@ export class DepartCustomerTruckDto { @IsArray() @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts index 1386e5bd4..35a4f7e0e 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -1,4 +1,5 @@ import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; +import { ISO_CONTAINER_NUMBER } from '@edr/types'; /** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ export class LoadCustomerTruckDto { @@ -7,7 +8,7 @@ export class LoadCustomerTruckDto { // A truck carries at most 2 containers (two 20ft, or one 40ft). @ArrayMaxSize(2) @ArrayUnique() - @Matches(/^[A-Z]{4}\d{7}$/, { + @Matches(ISO_CONTAINER_NUMBER, { each: true, message: 'each container number must match ISO container format, e.g. ABCD1234567', }) 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 f86c3e4bf..eda4b6386 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 @@ -105,6 +105,31 @@ export class RebookContainerLineDto { units!: RebookUnitDto[]; } +/** One edited container unit on the consolidation partner booking. */ +export class PartnerUnitPatchDto { + @ApiProperty({ description: 'Id of the partner booking container unit being edited' }) + @IsUUID() + id!: string; + + @ApiPropertyOptional({ description: 'Container number' }) + @IsOptional() + @IsString() + @MaxLength(64) + containerNumber?: string; + + @ApiPropertyOptional({ description: 'Seal number' }) + @IsOptional() + @IsString() + @MaxLength(64) + sealNumber?: string; + + @ApiPropertyOptional({ description: 'VGM (tons) of the unit' }) + @IsOptional() + @IsNumber() + @Min(0) + vgmTons?: number; +} + export class RebookCancelledWagonsDto { @ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' }) @IsDateString() @@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto { @IsOptional() @IsUUID() partnerBookingId?: string; + + @ApiPropertyOptional({ + description: + 'Corrections to the partner booking\'s own container units (number / seal ' + + '/ VGM). Only the units listed are touched; sizes and quantities are never ' + + 'changed. Ignored unless partnerBookingId is set.', + type: [PartnerUnitPatchDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PartnerUnitPatchDto) + partnerUnits?: PartnerUnitPatchDto[]; } export class FilterWagonCancellationsDto { @@ -183,6 +221,19 @@ export class CancelRemainingWagonsDto { @IsUUID('4') scheduleId!: string; + @ApiPropertyOptional({ + description: + 'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' + + 'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' + + 'Already-loaded wagons are rejected — they are riding.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonAllocationIds?: string[]; + @ApiProperty({ description: 'Why the remaining wagons are not riding' }) @IsString() @IsNotEmpty() diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index b86e1c3f3..9cf728a0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -543,6 +543,13 @@ export class Booking extends BaseEntity { @Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true }) wagonsRequired?: number | null; + // Wagon footprint pinned for cancellation pricing. `wagonsRequired` above is + // a LIVE scheduling field that unassign clears; this one is stamped once at + // first allocation and never cleared, so a paid booking pulled off a train + // can still price its cancellation fee and credit. + @Column({ name: 'cancellation_wagons', type: 'numeric', precision: 6, scale: 2, nullable: true }) + cancellationWagons?: number | null; + @Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' }) schedulingStatus!: string; diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.spec.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.spec.ts new file mode 100644 index 000000000..05dc84b29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.spec.ts @@ -0,0 +1,78 @@ +import 'reflect-metadata'; + +import { NotificationType, type NotifyInput } from '@edr/types'; + +import type { ChatConfig } from '../../config/chat.config'; +import { ChatBridgeService } from './chat-bridge.service'; +import type { MatrixClient } from './matrix.client'; + +const config: ChatConfig = { + enabled: true, + baseUrl: 'https://matrix.test', + publicBaseUrl: 'https://matrix.test', + webUrl: 'https://chat.test', + serverName: 'matrix.test', + jwtSecret: 'secret', + adminToken: 'syt_whatever', +}; + +function harness(overrides: Partial = {}) { + const matrix = { + ensureRoom: jest.fn(async (alias: string) => `!${alias}:matrix.test`), + sendMessage: jest.fn( + async (_roomId: string, _body: string, _html?: string) => undefined, + ), + }; + const service = new ChatBridgeService( + { ...config, ...overrides }, + matrix as unknown as MatrixClient, + ); + return { service, matrix }; +} + +const notification = (type: NotificationType): NotifyInput => + ({ type, title: 'Booking BK-1', body: 'needs review' }) as unknown as NotifyInput; + +describe('ChatBridgeService', () => { + it('posts every notification type into #freight-alerts', async () => { + // This used to route REQUEST_SUBMITTED and CLEARANCE_REVIEW to a hardcoded + // `dept-operation` alias, but the reconcile derives dept aliases from the + // IAM position key (`edr_freight_app/opn` shaped), so nothing it created + // ever matched. The bridge made its own empty room and posted there, where + // no employee was a member. + const { service, matrix } = harness(); + + for (const type of [ + NotificationType.REQUEST_SUBMITTED, + NotificationType.CLEARANCE_REVIEW, + NotificationType.GENERIC, + ]) { + await service.bridge(notification(type)); + } + + expect(new Set(matrix.ensureRoom.mock.calls.map(([alias]) => alias))).toEqual( + new Set(['freight-alerts']), + ); + expect(matrix.sendMessage).toHaveBeenCalledTimes(3); + }); + + it('does nothing at all when chat is switched off', async () => { + const { service, matrix } = harness({ enabled: false }); + + await service.bridge(notification(NotificationType.GENERIC)); + + expect(matrix.ensureRoom).not.toHaveBeenCalled(); + expect(matrix.sendMessage).not.toHaveBeenCalled(); + }); + + it('never lets a chat failure escape into the notification that triggered it', async () => { + // Same contract as NotificationInboxService.notify(): bridging is + // best-effort and must not roll back the caller's transaction. + const { service, matrix } = harness(); + matrix.ensureRoom.mockRejectedValueOnce(new Error('Matrix POST ... -> 429')); + + await expect( + service.bridge(notification(NotificationType.GENERIC)), + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts index 75bbe9a29..55c98b897 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts @@ -1,28 +1,11 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; -import { NotificationType, type NotifyInput } from '@edr/types'; +import type { NotifyInput } from '@edr/types'; import chatConfig from '../../config/chat.config'; +import { ALERTS_ROOM } from './chat-provisioning.service'; import { MatrixClient } from './matrix.client'; -const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; - -/** - * Best-effort per-type routing to an existing dept room. Anything not listed - * (including GENERIC) falls through to #freight-alerts — safer than a wrong - * guess at which department a type belongs to. Extend as real usage shows - * which types actually want a dept room instead of the shared feed. - * - * `name` matters only if this bridge is the very first thing to touch that - * alias (normally the nightly/on-demand reconcile creates dept rooms first, - * with the position's real name) — ensureRoom never renames an existing - * room, so this must match what ChatProvisioningService would have used. - */ -const ROOM_FOR_TYPE: Partial> = { - [NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' }, - [NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' }, -}; - /** * Mirrors BACKOFFICE-audience notifications into chat so staff see them * without having the inbox open. Hooked once into @@ -31,6 +14,15 @@ const ROOM_FOR_TYPE: Partial${escapeHtml(input.title)}
${escapeHtml(input.body)}${ input.link ? `
${escapeHtml(input.link)}` : '' diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts new file mode 100644 index 000000000..3b06cc518 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.spec.ts @@ -0,0 +1,194 @@ +import 'reflect-metadata'; + +import type { DataSource } from 'typeorm'; + +import { ChatProvisioningService } from './chat-provisioning.service'; +import type { MatrixClient } from './matrix.client'; + +const NAA = '03f5eb9e-23a0-4413-8d98-8de4b98b1be2'; +const SUPER_ADMIN = 'f1534714-fa4a-4780-a081-05d4c1f6c25f'; +const BOT = '@edrbot:m.test'; + +interface Holder { + positionKey: string; + positionName: string; + userId: string; + userName: string; +} + +const holder = ( + userId: string, + userName: string, + positionKey: string, + positionName = positionKey, +): Holder => ({ positionKey, positionName, userId, userName }); + +/** + * `members` maps a room id to who Matrix currently reports as joined, so a + * test can put a leaver in a room and watch what the reconcile does about it. + */ +function harness(holders: Holder[], members: Record = {}) { + const matrix = { + mxidFor: jest.fn( + (userId: string, name: string) => `@${name}.${userId.slice(0, 6)}:m.test`, + ), + whoami: jest.fn(async () => BOT), + ensureUser: jest.fn(async (_mxid: string, _name?: string) => undefined), + ensureRoom: jest.fn( + async (alias: string, _name?: string, _opts?: unknown) => `!${alias}:m.test`, + ), + ensureJoined: jest.fn(async (_roomId: string, _mxid: string) => undefined), + joinedMembers: jest.fn(async (roomId: string) => members[roomId] ?? [BOT]), + kick: jest.fn(async (_roomId: string, _mxid: string, _reason: string) => undefined), + lockUser: jest.fn(async (_mxid: string) => undefined), + }; + const dataSource = { query: jest.fn(async () => holders) }; + const service = new ChatProvisioningService( + dataSource as unknown as DataSource, + matrix as unknown as MatrixClient, + ); + return { service, matrix, dataSource }; +} + +/** + * `joinUserRooms` is the only thing standing between a first sign-in and an + * empty Element — the reconcile that would otherwise fill the room list runs + * nightly. + */ +describe('ChatProvisioningService.joinUserRooms', () => { + it('creates nothing for a user holding no current position', async () => { + // Super Admin on dev: three iam.employees rows, zero employee_positions. + // Synapse still auto-registers the account on JWT login, so the only + // symptom is a working sign-in into a client with no rooms in it. + const { service, matrix } = harness([]); + + await expect(service.joinUserRooms(SUPER_ADMIN, 'Super Admin')).resolves.toBe(0); + + expect(matrix.ensureUser).not.toHaveBeenCalled(); + expect(matrix.ensureRoom).not.toHaveBeenCalled(); + expect(matrix.ensureJoined).not.toHaveBeenCalled(); + }); + + it('joins a holder to the space, #general, #freight-alerts and their dept room', async () => { + const { service, matrix } = harness([ + holder(NAA, 'naa', 'edr_freight_app/marketer', 'Marketer'), + ]); + + await expect(service.joinUserRooms(NAA, 'naa')).resolves.toBe(4); + + // The account has to exist before the admin join API will touch it — JWT + // auto-registration happens after this runs. + expect(matrix.ensureUser).toHaveBeenCalledWith('@naa.03f5eb:m.test', 'naa'); + + expect(matrix.ensureRoom.mock.calls.map(([alias]) => alias)).toEqual([ + 'edr-freight', + 'general', + 'freight-alerts', + 'dept-edr_freight_app/marketer', + ]); + + // The space itself is joined, not only the rooms under it: Element shows a + // space in the left rail only to its members, so dropping this scatters + // every dept room loose into Home. #freight-alerts is joined here too, or + // a new hire sees no bridged notification until the nightly reconcile. + expect(matrix.ensureJoined.mock.calls.map(([roomId]) => roomId)).toEqual([ + '!edr-freight:m.test', + '!general:m.test', + '!freight-alerts:m.test', + '!dept-edr_freight_app/marketer:m.test', + ]); + }); + + it('scopes the position lookup to the one user', async () => { + const { service, dataSource } = harness([]); + await service.joinUserRooms(NAA, 'naa'); + // Without the third parameter this would reconcile the whole unit on every + // click of "Open EDR Chat". + const [sql, params] = dataSource.query.mock.calls[0] as unknown as [ + string, + unknown[], + ]; + expect(sql).toContain('AND e.user_id = $3'); + expect(params).toEqual(['edr_freight', 'edr_freight_app', NAA]); + }); +}); + +describe('ChatProvisioningService.reconcile', () => { + it('aborts instead of emptying every room when the holder query returns nothing', async () => { + // Zero holders never means "every employee left at once" — it means the + // query failed, the org/unit keys drifted, or a migration is mid-flight. + // Acting on it would kick every member of every room and lock every + // account, which is exactly the outage this guard exists to prevent. + const { service, matrix } = harness([]); + + await expect(service.reconcile()).rejects.toThrow(/no current position holders/i); + + expect(matrix.kick).not.toHaveBeenCalled(); + expect(matrix.lockUser).not.toHaveBeenCalled(); + }); + + it('locks a departed member rather than deactivating them', async () => { + const leaver = '@gone.999999:m.test'; + const { service, matrix } = harness( + [holder(NAA, 'naa', 'marketer', 'Marketer')], + { + '!edr-freight:m.test': [BOT, '@naa.03f5eb:m.test', leaver], + '!general:m.test': [BOT, '@naa.03f5eb:m.test', leaver], + '!freight-alerts:m.test': [BOT, '@naa.03f5eb:m.test'], + '!dept-marketer:m.test': [BOT, '@naa.03f5eb:m.test'], + }, + ); + + const result = await service.reconcile(); + + expect(matrix.kick.mock.calls.map(([, mxid]) => mxid)).toEqual([leaver, leaver]); + // Locking is reversible; deactivation is not, and on a homeserver with no + // password login it cannot be undone at all. + expect(matrix.lockUser).toHaveBeenCalledTimes(1); + expect(matrix.lockUser).toHaveBeenCalledWith(leaver); + expect(result.locked).toBe(1); + }); + + it('does not lock someone who only moved between positions', async () => { + const naaMxid = '@naa.03f5eb:m.test'; + // naa holds `marketer` now; the room for their old position still lists them. + const { service, matrix } = harness( + [ + holder(NAA, 'naa', 'marketer', 'Marketer'), + holder('aaa04914-b7ee-47b3-9c63-4324046a26bd', 'nati', 'opn', 'Operation'), + ], + { '!dept-opn:m.test': [BOT, naaMxid, '@nati.aaa049:m.test'] }, + ); + + const result = await service.reconcile(); + + expect(matrix.kick).toHaveBeenCalledWith( + '!dept-opn:m.test', + naaMxid, + expect.any(String), + ); + // Kicked from one room, still current elsewhere — their account stays open. + expect(matrix.lockUser).not.toHaveBeenCalled(); + expect(result.locked).toBe(0); + }); + + it('refuses to empty a populated room when its desired set is empty', async () => { + // Per-room backstop for the paths the unit-level guard above cannot see. + const { service, matrix } = harness([holder(NAA, 'naa', 'marketer')], { + '!room:m.test': [BOT, '@naa.03f5eb:m.test', '@nati.aaa049:m.test'], + }); + + const diff = await ( + service as unknown as { + syncMembership: ( + roomId: string, + desired: Set, + bot: string, + ) => Promise<{ joined: number; kicked: string[] }>; + } + ).syncMembership('!room:m.test', new Set(), BOT); + + expect(diff).toEqual({ joined: 0, kicked: [] }); + expect(matrix.kick).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts index b122d8073..102cb071a 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -13,6 +13,11 @@ const UNIT_KEY = 'edr_freight_app'; const SPACE_ALIAS = 'edr-freight'; const GENERAL_ALIAS = 'general'; +/** Where ChatBridgeService mirrors backoffice notifications. Provisioned here, + * with every position holder in it, so bridged messages land somewhere staff + * actually are — the bridge only ever get-or-creates it as a safety net. */ +export const ALERTS_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; + interface PositionHolder { positionKey: string; positionName: string; @@ -24,7 +29,8 @@ export interface ReconcileResult { rooms: number; joined: number; kicked: number; - deactivated: number; + /** Departed accounts locked — reversible. See {@link MatrixClient.lockUser}. */ + locked: number; } /** @@ -55,7 +61,7 @@ export class ChatProvisioningService { const result = await this.reconcile(); this.logger.log( `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + - `${result.kicked} kicked, ${result.deactivated} deactivated`, + `${result.kicked} kicked, ${result.locked} locked`, ); } catch (err) { // Never throws into the scheduler — chat provisioning must not be able @@ -113,10 +119,23 @@ export class ChatProvisioningService { const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { isSpace: true, }); + // The space itself, not only the rooms under it: Element lists a space in + // the left rail only for members of that space, so skipping this scatters + // every dept room loose into Home and the "EDR Freight" grouping never + // appears at all. + await this.matrix.ensureJoined(spaceId, mxid); const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { parentSpaceId: spaceId, }); await this.matrix.ensureJoined(generalRoomId, mxid); + // Without this a new hire sees no bridged notification until the nightly + // reconcile puts them in the alerts room. + const alertsRoomId = await this.matrix.ensureRoom( + ALERTS_ROOM.alias, + ALERTS_ROOM.name, + { parentSpaceId: spaceId }, + ); + await this.matrix.ensureJoined(alertsRoomId, mxid); for (const position of positions) { const roomId = await this.matrix.ensureRoom( @@ -127,10 +146,10 @@ export class ChatProvisioningService { await this.matrix.ensureJoined(roomId, mxid); } - return positions.length + 1; + return positions.length + 3; // space + general + alerts } - /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ + /** Force-joins additions, kicks users no longer entitled to this room. */ private async syncMembership( roomId: string, desiredUserIds: Set, @@ -139,6 +158,19 @@ export class ChatProvisioningService { const current = await this.matrix.joinedMembers(roomId); const currentSet = new Set(current.filter((id) => id !== botMxid)); + // An empty desired set against a populated room is not "everyone left" — + // it is a query that failed, a key that drifted, or a migration caught + // mid-flight. Acting on it would clear the room and then lock every + // account that was in it. {@link reconcile} guards the same shape at the + // unit level; this is the per-room backstop for the paths it cannot see. + if (desiredUserIds.size === 0 && currentSet.size > 0) { + this.logger.warn( + `Refusing to empty room ${roomId}: desired membership is empty while ` + + `${currentSet.size} member(s) are joined. Left untouched.`, + ); + return { joined: 0, kicked: [] }; + } + let joined = 0; for (const userId of desiredUserIds) { if (!currentSet.has(userId)) { @@ -160,6 +192,17 @@ export class ChatProvisioningService { async reconcile(): Promise { const holders = await this.currentHolders(); + // The desired state for the whole unit. Empty means the IAM query failed, + // the org/unit keys drifted, or a migration is mid-flight — it never means + // every employee left at once. Continuing would kick every member of every + // room and lock every account, so refuse the run and keep yesterday's + // state, which is wrong at worst by a day. + if (holders.length === 0) { + throw new Error( + `Chat reconcile aborted: no current position holders for ${ORG_KEY}/${UNIT_KEY}. ` + + 'Refusing to read that as "remove everyone".', + ); + } const botMxid = await this.matrix.whoami(); const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { @@ -168,6 +211,11 @@ export class ChatProvisioningService { const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { parentSpaceId: spaceId, }); + const alertsRoomId = await this.matrix.ensureRoom( + ALERTS_ROOM.alias, + ALERTS_ROOM.name, + { parentSpaceId: spaceId }, + ); const allUserIds = new Set( holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)), @@ -184,19 +232,32 @@ export class ChatProvisioningService { await this.matrix.ensureUser(mxid, h.userName); } - let rooms = 2; // space + general + let rooms = 3; // space + general + alerts let joined = 0; let kicked = 0; // A user kicked from anything while holding zero current positions // anywhere in the unit (allUserIds spans every position) is a full - // leaver, not just moved between positions — deactivate their account. + // leaver, not just moved between positions — lock their account. const kickedUserIds = new Set(); + // Space membership follows the org tree exactly like room membership — + // see the ensureJoined in joinUserRooms for why the space needs joining + // at all. + const spaceDiff = await this.syncMembership(spaceId, allUserIds, botMxid); + joined += spaceDiff.joined; + kicked += spaceDiff.kicked.length; + spaceDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); joined += generalDiff.joined; kicked += generalDiff.kicked.length; generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + const alertsDiff = await this.syncMembership(alertsRoomId, allUserIds, botMxid); + joined += alertsDiff.joined; + kicked += alertsDiff.kicked.length; + alertsDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + const byPosition = new Map }>(); for (const h of holders) { const entry = byPosition.get(h.positionKey) ?? { @@ -219,19 +280,19 @@ export class ChatProvisioningService { diff.kicked.forEach((uid) => kickedUserIds.add(uid)); } - let deactivated = 0; + let locked = 0; for (const userId of kickedUserIds) { if (allUserIds.has(userId)) continue; // moved position, still current elsewhere try { - await this.matrix.deactivateUser(userId); - deactivated += 1; + await this.matrix.lockUser(userId); + locked += 1; } catch (err) { this.logger.warn( - `Failed to deactivate departed user ${userId}: ${(err as Error).message}`, + `Failed to lock departed user ${userId}: ${(err as Error).message}`, ); } } - return { rooms, joined, kicked, deactivated }; + return { rooms, joined, kicked, locked }; } } diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts index 8df827339..db2e9c44c 100644 --- a/apps/edr-freight-api/src/modules/chat/chat.module.ts +++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts @@ -11,6 +11,8 @@ import { MatrixClient } from './matrix.client'; providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService], // ChatBridgeService: consumed by NotificationInboxModule to mirror // BACKOFFICE notifications into chat — see notification-inbox.module.ts. - exports: [ChatBridgeService], + // MatrixClient: HealthModule's readiness probe reports whether + // MATRIX_ADMIN_TOKEN really carries server-admin rights. + exports: [ChatBridgeService, MatrixClient], }) export class ChatModule {} diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts index ea5faa978..22cf8afd8 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts @@ -1,4 +1,5 @@ -import { chatLocalpart } from './matrix.client'; +import type { ChatConfig } from '../../config/chat.config'; +import { MatrixClient, chatLocalpart } from './matrix.client'; describe('chatLocalpart', () => { it('reads from the name, not the id', () => { @@ -33,3 +34,180 @@ describe('chatLocalpart', () => { } }); }); + +const config: ChatConfig = { + enabled: true, + baseUrl: 'https://matrix.test', + publicBaseUrl: 'https://matrix.test', + webUrl: 'https://chat.test', + serverName: 'matrix.test', + jwtSecret: 'secret', + adminToken: 'syt_whatever', +}; + +type FetchFn = typeof globalThis.fetch; + +/** Just enough of a Response for {@link MatrixClient}'s fetch wrappers. */ +function response(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +const realFetch: FetchFn = globalThis.fetch; +const fetchMock = jest.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + globalThis.fetch = fetchMock as unknown as FetchFn; +}); + +afterAll(() => { + globalThis.fetch = realFetch; +}); + +describe('MatrixClient.verifyServerAdmin', () => { + it('accepts a token that can actually call the Synapse admin API', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [], total: 1 })); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check).toEqual({ ok: true, actingAs: '@edrbot:matrix.test' }); + // The admin ping is the check. If this ever regresses to whoami alone, + // the assertion below is what catches it. + expect(String(fetchMock.mock.calls[1][0])).toContain('/_synapse/admin/'); + }); + + it('rejects a valid token that is not a server admin', async () => { + // The dev outage, exactly: MATRIX_ADMIN_TOKEN held @super-admin's own + // token. whoami answered 200, every /_synapse/admin call answered 403, + // ensureUser threw, ChatSsoService swallowed it, and every employee got a + // working sign-in into an Element with no rooms in it. + fetchMock + .mockResolvedValueOnce( + response(200, { user_id: '@super-admin.f15347:matrix.test' }), + ) + .mockResolvedValueOnce( + response(403, { + errcode: 'M_FORBIDDEN', + error: 'You are not a server admin', + }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + // Naming the account the token belongs to is the whole point — it is what + // turns "chat is broken" into "wrong token in the env". + expect(check.actingAs).toBe('@super-admin.f15347:matrix.test'); + expect(check.error).toContain('403'); + }); + + it('rejects a token that is not valid at all', async () => { + fetchMock.mockResolvedValueOnce( + response(401, { errcode: 'M_UNKNOWN_TOKEN', error: 'Invalid access token' }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + expect(check.actingAs).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); // no point pinging admin after this + }); +}); + +describe('MatrixClient.ensureUser', () => { + it('lifts the lock on a returning employee', async () => { + // A previous reconcile locked them as a leaver. Force-joining them back + // into rooms while they still cannot log in is a silent half-restore. + fetchMock + .mockResolvedValueOnce( + response(200, { name: '@naa.03f5eb:matrix.test', locked: true }), + ) + .mockResolvedValueOnce(response(200, {})); + + await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [url, init] = fetchMock.mock.calls[1] as [string, { body: string }]; + expect(String(url)).toContain('/_synapse/admin/v2/users/'); + expect(JSON.parse(init.body)).toEqual({ locked: false }); + }); + + it('leaves an account that is not locked alone', async () => { + fetchMock.mockResolvedValueOnce( + response(200, { name: '@naa.03f5eb:matrix.test', locked: false }), + ); + + await new MatrixClient(config).ensureUser('@naa.03f5eb:matrix.test', 'naa'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('MatrixClient rate limiting', () => { + it('retries a 429 after the delay Synapse asks for', async () => { + // The dev outage: a reconcile is a burst of writes, Synapse throttled an + // m.space.child PUT, and one un-retried 429 threw the whole run away. + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce( + response(429, { + errcode: 'M_LIMIT_EXCEEDED', + error: 'Too Many Requests', + retry_after_ms: 1, + }), + ) + .mockResolvedValueOnce(response(200, { users: [] })); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('gives up rather than hanging on a homeserver that only ever 429s', async () => { + fetchMock.mockResolvedValue( + response(429, { errcode: 'M_LIMIT_EXCEEDED', retry_after_ms: 1 }), + ); + + const check = await new MatrixClient(config).verifyServerAdmin(); + + expect(check.ok).toBe(false); + expect(check.error).toContain('429'); + }); +}); + +describe('MatrixClient.adminCheck', () => { + it('does not re-hit Synapse on every readiness probe', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })); + + const client = new MatrixClient(config); + const first = await client.adminCheck(); + const second = await client.adminCheck(); + + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(2); // whoami + admin ping, once + }); + + it('re-checks when forced, so boot never reads a stale verdict', async () => { + fetchMock + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })) + .mockResolvedValueOnce(response(200, { user_id: '@edrbot:matrix.test' })) + .mockResolvedValueOnce(response(200, { users: [] })); + + const client = new MatrixClient(config); + await client.adminCheck(); + await client.adminCheck(true); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts index 1cd09ac33..4da85b339 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { OnApplicationBootstrap } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import chatConfig from '../../config/chat.config'; @@ -40,8 +41,27 @@ export function chatLocalpart(userId: string, displayName: string): string { return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`; } +/** Result of {@link MatrixClient.verifyServerAdmin}. */ +export interface AdminCheck { + ok: boolean; + /** Who MATRIX_ADMIN_TOKEN belongs to — present whenever the token is valid + * at all, including when it is valid but carries no admin rights. */ + actingAs?: string; + error?: string; +} + @Injectable() -export class MatrixClient { +export class MatrixClient implements OnApplicationBootstrap { + private readonly logger = new Logger(MatrixClient.name); + + /** The token is a deploy-time fact and the readiness probe runs every few + * seconds, so {@link adminCheck} memoises for this long. */ + private static readonly ADMIN_CHECK_TTL_MS = 5 * 60_000; + /** Enough to ride out Synapse's limiter; short enough that a genuinely + * wedged homeserver still fails the run rather than hanging it. */ + private static readonly MAX_RATE_LIMIT_RETRIES = 5; + private adminCheckCache?: { at: number; result: AdminCheck }; + constructor( @Inject(chatConfig.KEY) private readonly config: ConfigType, @@ -73,13 +93,48 @@ export class MatrixClient { return this.config.serverName; } + /** MATRIX_ENABLED — read by the readiness probe to tell "off" from "broken". */ + get enabled(): boolean { + return this.config.enabled; + } + + /** + * Synapse answers a burst of writes with 429 + `retry_after_ms`, and a + * reconcile is nothing but a burst of writes — one run creates the space, + * #general and a room per position, then force-joins every holder into each. + * The first run against dev tripped the limiter on an `m.space.child` PUT, + * and because nothing retried, that single 429 threw the whole reconcile + * away mid-flight. On the sign-in path ChatSsoService swallows the throw, so + * the only visible symptom was an empty Element. + * + * Honour the delay Synapse asks for rather than guessing at one. + */ + private async fetchWithRetry( + url: string, + init: Parameters[1], + ): Promise>> { + for (let attempt = 0; ; attempt++) { + const res = await fetch(url, init); + if (res.status !== 429 || attempt >= MatrixClient.MAX_RATE_LIMIT_RETRIES) { + return res; + } + // Body is discarded either way — this response is being retried. + const body = (await res.json().catch(() => ({}))) as { + retry_after_ms?: number; + }; + await new Promise((resolve) => + setTimeout(resolve, (Number(body.retry_after_ms) || 1000) + 100), + ); + } + } + private async request( method: string, path: string, body?: unknown, token: string = this.config.adminToken, ): Promise { - const res = await fetch(`${this.config.baseUrl}${path}`, { + const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json', @@ -103,7 +158,7 @@ export class MatrixClient { path: string, body: unknown, ): Promise { - const res = await fetch(`${this.config.baseUrl}${path}`, { + const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -123,7 +178,7 @@ export class MatrixClient { path: string, token?: string, ): Promise { - const res = await fetch(`${this.config.baseUrl}${path}`, { + const res = await this.fetchWithRetry(`${this.config.baseUrl}${path}`, { method, headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, }); @@ -157,6 +212,68 @@ export class MatrixClient { return res.user_id; } + /** + * Is MATRIX_ADMIN_TOKEN actually a *server admin* token? + * + * `whoami` cannot answer this: it returns 200 for any valid user token at + * all. Dev shipped with MATRIX_ADMIN_TOKEN holding an ordinary staff + * account's token — whoami said 200, every `/_synapse/admin/*` call said + * 403 "You are not a server admin", `ensureUser` threw, ChatSsoService + * swallowed it (by design — a failed room join must not deny anyone a + * sign-in link), and every employee got a working sign-in into a client + * with no rooms in it. Nothing else in the system noticed. + * + * So this pings an endpoint only a server admin may call, and reports who + * the token belongs to — the one fact that makes the mix-up obvious. + */ + async verifyServerAdmin(): Promise { + let actingAs: string | undefined; + try { + actingAs = await this.whoami(); + await this.request('GET', '/_synapse/admin/v2/users?limit=1'); + return { ok: true, actingAs }; + } catch (err) { + return { ok: false, actingAs, error: (err as Error).message }; + } + } + + /** {@link verifyServerAdmin}, memoised for {@link ADMIN_CHECK_TTL_MS}. */ + async adminCheck(force = false): Promise { + const cached = this.adminCheckCache; + if ( + !force && + cached && + Date.now() - cached.at < MatrixClient.ADMIN_CHECK_TTL_MS + ) { + return cached.result; + } + const result = await this.verifyServerAdmin(); + this.adminCheckCache = { at: Date.now(), result }; + return result; + } + + /** + * Fail loud at boot instead of silently on every sign-in. Logged, never + * thrown: chat provisioning must not be able to stop the API from starting, + * the same contract the reconcile cron and the notification bridge hold to. + */ + async onApplicationBootstrap(): Promise { + if (!this.config.enabled) return; + const check = await this.adminCheck(true); + if (check.ok) { + this.logger.log( + `MATRIX_ADMIN_TOKEN verified — server admin as ${check.actingAs}`, + ); + return; + } + this.logger.error( + 'MATRIX_ADMIN_TOKEN is not a server-admin token' + + (check.actingAs ? ` (it belongs to ${check.actingAs})` : '') + + `: ${check.error}. Chat provisioning will create no rooms, and every ` + + 'employee who opens chat will land in an empty Element.', + ); + } + /** Currently-joined user ids for a room (not full member-event state). */ async joinedMembers(roomId: string): Promise { const res = await this.request<{ joined: Record }>( @@ -249,11 +366,18 @@ export class MatrixClient { * ("User not found") on an account that doesn't exist yet. */ async ensureUser(userId: string, displayName?: string): Promise { - const existing = await this.requestOrNull<{ name: string }>( + const existing = await this.requestOrNull<{ name: string; locked?: boolean }>( 'GET', `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, ); - if (existing) return; + if (existing) { + // A returning employee is still locked from the reconcile that saw them + // leave. Force-joining them into rooms while they cannot log in is a + // silent half-restore, and this is the one call that already knows the + // flag — so undo it here rather than making the caller ask again. + if (existing.locked) await this.setLocked(userId, false); + return; + } await this.request( 'PUT', `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, @@ -293,12 +417,30 @@ export class MatrixClient { ); } - /** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ - deactivateUser(userId: string): Promise { + /** + * Lock a departed employee out of chat — reversible, unlike deactivation. + * + * This used to call `/_synapse/admin/v1/deactivate`. That revokes sessions + * the same way but cannot be undone in any useful sense on this deployment: + * reactivation wants a password, and `password_config.enabled: false` means + * there is none to set. Room memberships do not come back either. One bad + * reconcile — a half-applied IAM migration, a renamed org key — would have + * destroyed every staff account that way, permanently. + * + * Locking blocks exactly the same access (Synapse rejects the account's + * tokens with M_USER_LOCKED and refuses new logins) and is undone with a + * single PUT — see {@link ensureUser}, which lifts it automatically when + * someone comes back. + */ + lockUser(userId: string): Promise { + return this.setLocked(userId, true); + } + + private setLocked(userId: string, locked: boolean): Promise { return this.request( - 'POST', - `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, - { erase: false }, + 'PUT', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + { locked }, ); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 000b0733f..733b6e441 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -57,8 +57,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { AccountInfoResponse, ShippingLineInfoResponseDto, + TransitAgentInfoResponseDto, } from "./dto/account-info-response.dto"; import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service"; +import { TransitAgentsService } from "../transit-agents/transit-agents.service"; import { UpdateProfileDto } from "./dto/update-profile.dto"; import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; @@ -104,6 +106,7 @@ export class CompaniesController { private readonly companiesService: CompaniesService, private readonly filesService: FilesService, private readonly shippingLineCompaniesService: ShippingLineCompaniesService, + private readonly transitAgentsService: TransitAgentsService, ) { } /** @@ -133,10 +136,10 @@ export class CompaniesController { async getInfo( @CurrentUser() user: CurrentIamUser, ): Promise { - // A shipping line has no company and no external profile, so the customer - // lookup below would 404. Checked first, and reported with an explicit - // `accountKind` so the portal can skip onboarding for shipping lines - // without inferring it from a missing company. + // Neither a shipping line nor a transit agent has a company or an external + // profile, so the customer lookup below would 404 for both. Checked first, + // and reported with an explicit `accountKind` so the portal can skip + // onboarding for them without inferring it from a missing company. const shippingLine = await this.shippingLineCompaniesService.findByUserId( user.id, ); @@ -144,6 +147,11 @@ export class CompaniesController { return new ShippingLineInfoResponseDto(shippingLine); } + const transitAgent = await this.transitAgentsService.findByUserId(user.id); + if (transitAgent) { + return new TransitAgentInfoResponseDto(transitAgent); + } + const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); const review = await this.companiesService.getOpenChangeRequestForCompany( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 666634cb8..3a3d6c1c4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity"; import { CompanyRevision } from "./entities/company-revision.entity"; import { Booking } from "../bookings/entities/booking.entity"; import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module"; +import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; import { CompanyProfileRepository } from "./company-profile.repository"; import { CompanyChangeRequestRepository } from "./company-change-request.repository"; import { CompanyRevisionRepository } from "./company-revision.repository"; @@ -49,6 +50,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module"; // shipping-line session, which has no company row to look up. forwardRef // because that module imports BillingModule, which imports this one. forwardRef(() => ShippingLineCompaniesModule), + // `GET /companies/getInfo` resolves a transit-agent session before falling + // through to the customer lookup. TransitAgentsModule is a leaf here — it + // does not import CompaniesModule — so no forwardRef is needed. + TransitAgentsModule, ], controllers: [CompaniesController], providers: [ diff --git a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts index ab680f8f3..9a43e65d4 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity"; +import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity"; import { CompanyInfoResponseDto } from "./company-info-response.dto"; /** @@ -9,10 +10,10 @@ import { CompanyInfoResponseDto } from "./company-info-response.dto"; * The portal keys its onboarding gate off this rather than off "is `company` * missing?": a failed or slow company fetch also leaves `company` empty, and * treating that as "no onboarding needed" would let customers skip onboarding - * whenever the request failed. A shipping line is identified positively, and - * anything else defaults to `customer`. + * whenever the request failed. A shipping line and a transit agent are each + * identified positively, and anything else defaults to `customer`. */ -export type AccountKind = "customer" | "shipping_line"; +export type AccountKind = "customer" | "shipping_line" | "transit_agent"; /** The signed-in shipping line. No company, no profile, no onboarding. */ export class ShippingLineInfoResponseDto { @@ -61,6 +62,62 @@ export class ShippingLineInfoResponseDto { } } +/** + * The signed-in transit agent. Like a shipping line: no company, no profile, no + * onboarding — but a separate account kind because the two share nothing beyond + * that, and the portal shows each a different (much smaller) set of tabs. + */ +export class TransitAgentInfoResponseDto { + @ApiProperty({ enum: ["transit_agent"] }) + accountKind: "transit_agent" = "transit_agent"; + + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + email?: string | null; + + @ApiPropertyOptional() + phoneNumber?: string | null; + + @ApiProperty() + isActive: boolean; + + @ApiProperty({ + description: "Start of the agent's validity window (yyyy-MM-dd)", + }) + validFrom: string; + + @ApiProperty({ + description: "End of the agent's validity window (yyyy-MM-dd)", + }) + validTo: string; + + /** Always null — see {@link ShippingLineInfoResponseDto.company}. */ + @ApiProperty({ nullable: true }) + company: null = null; + + @ApiProperty({ nullable: true }) + profile: null = null; + + @ApiProperty({ nullable: true }) + review: null = null; + + constructor(entity: TransitAgent) { + this.id = entity.id; + this.name = entity.name; + this.email = entity.email ?? null; + this.phoneNumber = entity.phoneNumber ?? null; + this.isActive = entity.isActive; + this.validFrom = entity.validFrom; + this.validTo = entity.validTo; + } +} + export type AccountInfoResponse = | (CompanyInfoResponseDto & { accountKind: "customer" }) - | ShippingLineInfoResponseDto; + | ShippingLineInfoResponseDto + | TransitAgentInfoResponseDto; diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts index 2f9d984e6..61cdacd79 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.service.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -8,6 +8,8 @@ import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; import { Container } from './entities/container.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { WagonEventType } from '@edr/types'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; @Injectable() export class ContainersService { @@ -19,6 +21,7 @@ export class ContainersService { @InjectRepository(ContainerType) private readonly containerTypeRepo: Repository, private readonly dataSource: DataSource, + private readonly wagonHistory: WagonHistoryService, ) {} async create(dto: CreateContainerDto): Promise { @@ -150,7 +153,16 @@ export class ContainersService { // Placing a container on a wagon does not make it AVAILABLE. The status enum // (AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED) has no ASSIGNED/ON_WAGON // state, so leave the existing status unchanged rather than forcing AVAILABLE. - return containerRepo.save(container); + const saved = await containerRepo.save(container); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.ContainerPlaced, + toYardId: wagon.currentYardId ?? null, + toValue: container.containerNumber, + metadata: { containerId: container.id, position }, + }); + return saved; }); } @@ -159,9 +171,23 @@ export class ContainersService { if (container.status === 'LOADED') { throw new ConflictException('Cannot unassign a loaded container'); } + const previousWagonId = container.wagonId; + const previousPosition = container.position ?? null; container.wagonId = null; container.position = null; container.status = 'AVAILABLE'; - return this.containerRepo.save(container); + const saved = await this.containerRepo.save(container); + if (previousWagonId) { + const wagon = await this.wagonRepo.findOne({ where: { id: previousWagonId } }); + await this.wagonHistory.record(null, { + wagonId: previousWagonId, + wagonNumber: wagon?.wagonNumber ?? null, + type: WagonEventType.ContainerRemoved, + fromYardId: wagon?.currentYardId ?? null, + fromValue: container.containerNumber, + metadata: { containerId: container.id, position: previousPosition }, + }); + } + return saved; } } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 5cee12d3b..faa59e802 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -43,6 +43,9 @@ function makeService(overrides?: { findBookingsWithUnreviewedDocuments: jest .fn() .mockResolvedValue(new Set()), + findBookingsWithRedeemableCredit: jest + .fn() + .mockResolvedValue(new Map()), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), @@ -116,6 +119,7 @@ function makeService(overrides?: { .fn() .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), } as never, // transit agents + { ensureAssignment: jest.fn() } as never, // transit assignments { findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository { getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope { record: jest.fn() } as never, // clearanceEvents diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index b4cead816..5d0943fe6 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { In } from 'typeorm'; import { ContractDocPhase, @@ -30,10 +30,12 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service'; import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitArrivalUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, transitArrivalDocumentMatcher } from './phased-clearance.util'; +import type { TransitArrivalDocumentKind } from '@edr/types'; import { buildClearanceDocHistory, @@ -45,6 +47,8 @@ import { clearanceDocumentsOpen } from '../bookings/clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; export interface BookingClearanceView { + /** Booking creation stamp — the import DO is timed from it. */ + bookingCreatedAt?: string | null; bookingId: string; status: string; includesCustoms: boolean; @@ -176,6 +180,7 @@ export class BookingClearanceService { private readonly notifier: BookingLifecycleNotifierService, private readonly glExchangeService: GlExchangeService, private readonly transitAgentsService: TransitAgentsService, + private readonly transitAssignmentsService: TransitAssignmentsService, private readonly contractsRepository: ContractsRepository, private readonly yardScope: YardScopeService, private readonly clearanceEvents: ClearanceEventService, @@ -365,6 +370,7 @@ export class BookingClearanceService { return { bookingId, status: booking.status, + bookingCreatedAt: booking.createdAt ? new Date(booking.createdAt).toISOString() : null, includesCustoms, inputCode, outputCode, @@ -385,6 +391,7 @@ export class BookingClearanceService { status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, + triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null, sortOrder: m.sortOrder, })), nextAction, @@ -593,6 +600,17 @@ export class BookingClearanceService { transitAssigneeName: agent.name, transitAssigneeAssignedAt: new Date(), } as never); + + // The booking only stores the officer's NAME, which is what the clearance + // UI reads. The agent's own portal works off `transit_assignments` rows, so + // without this the shipment never reaches the officer's work list — the + // desk believes it handed the job over and nothing arrives. + await this.transitAssignmentsService.ensureAssignment( + bookingId, + transitAgentId, + userId, + ); + await this.clearanceEvents.record({ bookingId, action: 'TRANSIT_ASSIGNEE_ASSIGNED', @@ -1161,6 +1179,83 @@ export class BookingClearanceService { return { booking: await this.bookingsService.findById(bookingId), hold: false }; } + // ── Transit-agent arrival paperwork (export) ──────────────────────────── + // Gate pass and Djibouti T1 documents the assigned transit officer files at + // Djibouti around train arrival. Append-only sets with per-file removal — see + // `persistTransitArrivalUploads`. The clearance view stamps every file with + // its upload time, so the portal can measure it against train departure and + // arrival without a separate ledger. + + private static readonly TRANSIT_ARRIVAL_LABELS: Record< + TransitArrivalDocumentKind, + { name: string; uploaded: string; removed: string } + > = { + gate_pass: { + name: 'gate pass', + uploaded: 'GATE_PASS_DOCUMENTS_UPLOADED', + removed: 'GATE_PASS_DOCUMENT_REMOVED', + }, + djibouti_t1: { + name: 'Djibouti T1', + uploaded: 'DJIBOUTI_T1_DOCUMENTS_UPLOADED', + removed: 'DJIBOUTI_T1_DOCUMENT_REMOVED', + }, + }; + + async uploadTransitArrivalDocuments( + bookingId: string, + kind: TransitArrivalDocumentKind, + files: Express.Multer.File[], + userId?: string, + ): Promise<{ uploaded: number }> { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'Gate pass and Djibouti T1 documents apply only to export bookings.', + ); + } + const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind]; + await persistTransitArrivalUploads(this.filesService, bookingId, kind, files ?? [], userId); + await this.clearanceEvents.record({ + bookingId, + action: labels.uploaded, + label: `Uploaded ${files.length} ${labels.name} document(s)`, + actorId: userId ?? null, + metadata: { kind, fileNames: (files ?? []).map((f) => f.originalname) }, + }); + return { uploaded: files.length }; + } + + /** + * Remove ONE gate pass / Djibouti T1 file. Only those two code families are + * removable here: the route is reachable by the transit agent, and it must + * never become a way to delete a declaration or a Release Order. + */ + async removeTransitArrivalDocument( + bookingId: string, + fileId: string, + userId?: string, + ): Promise { + await this.loadBooking(bookingId); + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const file = files.find((f) => f.id === fileId); + const kind = (['gate_pass', 'djibouti_t1'] as const).find((k) => + transitArrivalDocumentMatcher(k)(file?.code), + ); + if (!file || !kind) { + throw new NotFoundException('Document not found on this booking.'); + } + await this.filesService.remove(fileId); + const labels = BookingClearanceService.TRANSIT_ARRIVAL_LABELS[kind]; + await this.clearanceEvents.record({ + bookingId, + action: labels.removed, + label: `Removed ${labels.name} document ${file.name}`, + actorId: userId ?? null, + metadata: { kind, fileName: file.name }, + }); + } + async requestRoAmendment( bookingId: string, note?: string, @@ -1252,6 +1347,17 @@ export class BookingClearanceService { .hasDocumentsAwaitingReview = pending.has(b.id); } + // A cancelled booking may still hold a paid-for wagon-cancellation credit. + // GL redeems it from this queue, so the row carries the cancellation id the + // rebook action needs. + const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit( + filtered.map((b) => b.id), + ); + for (const b of filtered) { + (b as Booking & { rebookableCancellationId?: string | null }) + .rebookableCancellationId = credits.get(b.id) ?? null; + } + const rows = await this.attachContractSummary(filtered); return this.narrowToYardScope(rows, user); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index 49e77627f..b601e1c3e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { { quantity: 4, containerType: { sizeFt: 20 } }, ], }, - // A bare instance has no cargo yet — GL enters it on the split form, so it - // stays a candidate. + // Cargo not entered yet — its 20ft count is unknown, so it cannot be + // shown to fill the wagon and is not offered. { id: 'bare', reference: 'BK-BARE', bookingContainers: [] }, ]; @@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { rows.filter((row) => { void booking; const lines = row.bookingContainers ?? []; - if (lines.length === 0) return true; + if (lines.length === 0) return false; const ft20 = lines .filter((l) => Number(l.containerType?.sizeFt) === 20) .reduce((sum, l) => sum + Number(l.quantity || 0), 0); @@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { }); const candidates = await service.listConsolidationCandidates('c-1', 'b-1'); - expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']); + expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']); expect(candidates[0].ft20Quantity).toBe(3); - expect(candidates[1].hasCargo).toBe(false); + expect(candidates[0].hasCargo).toBe(true); }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index c95eb73b7..c15d20800 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -31,6 +31,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service'; import { ClearanceMilestone, type RiskAssignmentRecord, @@ -185,6 +186,7 @@ export class ContractClearanceService { private readonly glOperationsService: GlOperationsService, private readonly notifier: ContractNotifierService, private readonly transitAgentsService: TransitAgentsService, + private readonly transitAssignmentsService: TransitAssignmentsService, private readonly dataSource: DataSource, ) {} @@ -480,6 +482,7 @@ export class ContractClearanceService { status: m.status, ownerRegion: m.ownerRegion, metadata: (m.metadata ?? null) as Record | null, + triggeredAt: m.triggeredAt ? new Date(m.triggeredAt).toISOString() : null, sortOrder: m.sortOrder, })), nextAction, @@ -1230,6 +1233,18 @@ export class ContractClearanceService { transitAssigneeAssignedByUserId: userId ?? null, }); + // Mirror the name onto the officer's own work list, exactly as the + // per-booking path does. Contract-level clearance can be assigned before a + // booking exists; in that case there is nothing for the officer to work on + // yet, and the booking picks the assignment up when it is created. + if (cycle.bookingId) { + await this.transitAssignmentsService.ensureAssignment( + cycle.bookingId, + transitAgentId, + userId, + ); + } + const updated = await this.contractsService.findById(contractId); this.notifier.transitAssigneeAssigned(updated, agent.name, previous); return updated; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts index a7bd3cd7d..b35f9d844 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => { {} as never, // glOperationsService notifier as never, {} as never, // transitAgentsService + {} as never, // transitAssignmentsService {} as never, // dataSource ); build([ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 477724839..10b6afd37 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -176,6 +176,18 @@ export class ContractNotifierService { this.inApp(c, 'Contract suspension lifted', msg); } + /** + * Backoffice cancelled the contract. Terminal — the customer is told they may + * submit a new contract with the same details if they still need the service. + */ + cancelledByStaff(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` + + `If you still need this service you can submit a new contract request with the same details.`; + void this.notifyContact(c, msg, 'CANCELLED'); + this.inApp(c, 'Contract cancelled', msg); + } + /** Customer cancelled their own contract — staff-side record. */ cancelledByCustomer(c: Contract, reason: string): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts index 96a8a26ac..c535c909e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => { ]); }); }); + +describe('contract base freight ignores shipping-line rates', () => { + it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => { + // Both LIVE on the contract's own lane: the line rate sorted first and won, + // so the contract quoted 32 USD/wagon instead of the standard 1690. + const breakdown = await service([ + rate({ + containerTypeId: CT20, + rateValue: 32, + rateUnit: 'PER_WAGON', + shippingLineCompanyId: 'line-1', + }), + rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }), + ]).buildBreakdown(contract({})); + expect(breakdown.lineItems).toEqual([ + expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }), + ]); + }); + + it('blocks when the only rate on the lane belongs to a shipping line', async () => { + await expect( + service([ + rate({ + containerTypeId: CT20, + rateValue: 32, + shippingLineCompanyId: 'line-1', + }), + ]).buildBreakdown(contract({})), + ).rejects.toThrow(UnprocessableEntityException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 0af09a5f8..04be6460f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -85,7 +85,15 @@ export class ContractPricingService { * commodity rate) — NO totals or quantities (doc §9.1). */ async buildBreakdown(contract: Contract): Promise { - const liveRates = await this.ratesService.findLiveRates(); + // Contracts belong to a customer company — there is no shipping-line + // contract (no shipping_line_company_id on the entity), so a contract may + // only ever price off the standard rates. Without this filter a line's + // negotiated rate on the same lane matched first and the contract froze it + // for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and + // 23 USD/container 40ft instead of the standard 1690 / 1676. + const liveRates = (await this.ratesService.findLiveRates()).filter( + (r) => !r.shippingLineCompanyId, + ); const currency = contract.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts new file mode 100644 index 000000000..06a406bf1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts @@ -0,0 +1,113 @@ +import { ContractTransitionService } from './contract-transition.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * Staff cancel is terminal, so the rules that matter are: it needs its own + * permission (suspend must NOT imply it), it refuses to strand live shipments, + * it works on a suspended contract, and it cannot be applied twice. + */ +describe('ContractTransitionService — staff cancel', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + status: 'CONTRACT_ACTIVE', + freightType: 'CONTAINER', + ...over, + }) as Contract; + + let current: Contract; + let repo: { + update: jest.Mock; + createReviewNote: jest.Mock; + countActiveBookings: jest.Mock; + }; + let notifier: { cancelledByStaff: jest.Mock }; + let service: ContractTransitionService; + + const staff = { + permissions: [{ key: 'edr_freight_app:contracts:cancel' }], + }; + + beforeEach(() => { + current = contract(); + repo = { + update: jest.fn().mockImplementation((_id: string, patch: object) => { + current = { ...current, ...patch } as Contract; + return Promise.resolve(current); + }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + countActiveBookings: jest.fn().mockResolvedValue(0), + }; + notifier = { cancelledByStaff: jest.fn() }; + service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsRepository: repo, + contractsService: { findById: () => Promise.resolve(current) }, + notifier, + }); + }); + + it('cancels, records the reason as a staff note, and notifies the customer', async () => { + await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith('c-1', { + status: 'CANCELLED', + statusBeforeSuspension: null, + }); + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + 'Duplicate request', + 'CANCELLATION', + 'staff-1', + 'STAFF', + ); + expect(notifier.cancelledByStaff).toHaveBeenCalled(); + }); + + it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => { + current = contract({ + status: 'SUSPENDED', + statusBeforeSuspension: 'CONTRACT_ACTIVE', + } as Partial); + + await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith('c-1', { + status: 'CANCELLED', + statusBeforeSuspension: null, + }); + }); + + it('refuses while a shipment is still running', async () => { + repo.countActiveBookings.mockResolvedValue(2); + + await expect( + service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never), + ).rejects.toThrow('2 active shipments'); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('refuses to cancel an already-terminal contract', async () => { + current = contract({ status: 'CANCELLED' }); + + await expect( + service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never), + ).rejects.toThrow(/already cancelled/i); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('rejects a user holding only the suspend key — cancel is a separate permission', async () => { + const suspender = { + permissions: [{ key: 'edr_freight_app:contracts:suspend' }], + }; + + await expect( + service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never), + ).rejects.toThrow(); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 936176f86..ac400d9d3 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -1452,6 +1452,54 @@ export class ContractTransitionService { return updated; } + /** + * Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one + * with the same parameters can be submitted afterwards (references are minted + * per contract, so nothing about the old row blocks the new one). + * + * Cancellable from ANY non-terminal status, including SUSPENDED: a frozen + * contract is exactly the one staff most often need to kill outright. + */ + async cancelByStaff( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel); + if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) { + throw new ConflictException( + `Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`, + ); + } + + // Same guard as the customer path: live shipments must be settled first, + // otherwise cancelling the contract orphans cargo already in motion. + const active = await this.contractsRepository.countActiveBookings(contractId); + if (active > 0) { + throw new BadRequestException( + `This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` + + 'Cancel or complete them before cancelling the contract.', + ); + } + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CANCELLATION', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: 'CANCELLED', + statusBeforeSuspension: null, + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.cancelledByStaff(updated, reason); + return updated; + } + async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 1dc083934..7cf7a8398 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, HttpCode, + NotFoundException, Param, ParseUUIDPipe, Patch, @@ -72,6 +73,7 @@ import { RequestChangesDto, ResumeContractDto, SuspendContractDto, + CancelContractByStaffDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; @@ -552,6 +554,25 @@ export class ContractsController { ); } + @Post(':id/staff/cancel') + @BookingStaff(FREIGHT_PERMS.contracts.cancel) + @ApiOperation({ + summary: + 'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)', + }) + cancelByStaff( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelContractByStaffDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.cancelByStaff( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); + } + @Post(':id/approval-steps/:stepId/approve') @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) @@ -1339,19 +1360,35 @@ export class ContractsController { return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } + // Also filed by the transit agent assigned to the shipment — T1 is their own + // transit paperwork. Any other portal caller is rejected below. @Post('bookings/:bookingId/t1-documents') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', }) - uploadT1Documents( + async uploadT1Documents( @Param('bookingId', ParseUUIDPipe) bookingId: string, @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, ) { - return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) && + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + bookingId, + )) + ) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + return this.glOperationsService.uploadT1Documents( + bookingId, + files ?? [], + resolveAuthUserId(user), + ); } @Post('bookings/:bookingId/t1-close') @@ -1514,6 +1551,8 @@ export class ContractsController { @MixedAudience(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + // Reads are open to both audiences (a transit agent assigned to the + // shipment included); reporting an incident stays staff-only below. return this.glOperationsService.listIncidents(bookingId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 2a07b02e5..a198a0282 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -18,6 +18,7 @@ import { BookingsModule } from '../bookings/bookings.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractTemplatesModule } from '../contract-templates/contract-templates.module'; import { TransitAgentsModule } from '../transit-agents/transit-agents.module'; +import { TransitAssignmentsModule } from '../transit-assignments/transit-assignments.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -94,6 +95,10 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum // ContractDocumentViewModelBuilder when rendering contract PDFs. ContractTemplatesModule, TransitAgentsModule, + // Assigning a transit assignee must also land a row in the officer's own + // work list. This module is a leaf (it registers Booking as an entity + // rather than importing BookingsModule), so no cycle is closed here. + TransitAssignmentsModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 6aa36b13d..63057978c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -51,6 +51,14 @@ export class CancelContractDto { reason?: string; } +/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */ +export class CancelContractByStaffDto { + @ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' }) + @IsString() + @MinLength(1) + reason!: string; +} + export class SuspendContractDto { @ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' }) @IsString() diff --git a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts index 4875d9e24..25f798e68 100644 --- a/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/final-invoice-approval.spec.ts @@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => { {} as never, // milestoneService billingService as never, notifier as never, + { record: jest.fn() } as never, // clearanceEvents ); }); diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts index 6ea0dacbe..b387d9370 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, HttpCode, + NotFoundException, Param, ParseUUIDPipe, Patch, @@ -17,10 +18,11 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { actorLabel } from '../warehouses/current-actor.util'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, MixedAudience } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; +import { BookingsService } from '../bookings/bookings.service'; import { GlExchangeService, @@ -42,37 +44,61 @@ const asBool = (raw: string | boolean | undefined): boolean => @ApiBearerAuth() @Controller('gl-exchange') export class GlExchangeController { - constructor(private readonly exchangeService: GlExchangeService) {} + constructor( + private readonly exchangeService: GlExchangeService, + private readonly bookingsService: BookingsService, + ) {} + // Read opened to the transit agent assigned to the shipment; the POST/PATCH/ + // DELETE below stay staff-only, so an agent can read the desks' thread but + // never post to it. @Get(':entityId') - @BookingStaff(GL_EXCHANGE_PERMS) + @MixedAudience(GL_EXCHANGE_PERMS) @ApiOperation({ summary: 'GL ET ↔ GL DJ shared documents for a booking or contract', }) - list( + async list( @Param('entityId', ParseUUIDPipe) entityId: string, @CurrentUser() user: TCurrentUser, ) { + const isStaff = GL_EXCHANGE_PERMS.some((p) => + hasFreightPermission(user, p), + ); + if ( + !isStaff && + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + entityId, + )) + ) { + throw new NotFoundException(`Entity ${entityId} not found`); + } return this.exchangeService.list(entityId, resolveAuthUserId(user)); } + // Open to the transit agent assigned to the shipment as well as both desks: + // the officer on the ground is often the one holding the scan either desk + // needs. Their post is attributed to the TRANSIT side, never to a desk. @Post(':entityId') - @BookingStaff(GL_EXCHANGE_PERMS) + @MixedAudience(GL_EXCHANGE_PERMS) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Share a document with the other GL desk' }) - upload( + @ApiOperation({ + summary: 'Share a document with the GL desks (either desk, or the assigned transit agent)', + }) + async upload( @Param('entityId', ParseUUIDPipe) entityId: string, @UploadedFile() file: Express.Multer.File | undefined, @Body('title') title: string, @Body('visibleToCustomer') visibleToCustomer: string | undefined, @CurrentUser() user: TCurrentUser, ) { + const actor = await this.resolveActor(entityId, user); return this.exchangeService.upload( entityId, file, { title, visibleToCustomer: asBool(visibleToCustomer) }, - this.actor(user), + actor, ); } @@ -118,6 +144,36 @@ export class GlExchangeController { * is Djibouti; everyone else (GL Ethiopia, and super admins who hold both) * posts as Ethiopia. */ + /** + * Who is posting, for a route both desks and the assigned transit agent may + * call. Staff keep the desk attribution below; a portal caller must be the + * agent assigned to this shipment and posts as TRANSIT, so a document is + * never credited to a desk that did not send it. + */ + private async resolveActor( + entityId: string, + user: TCurrentUser, + ): Promise { + const isStaff = GL_EXCHANGE_PERMS.some((p) => + hasFreightPermission(user, p), + ); + if (isStaff) return this.actor(user); + + if ( + !(await this.bookingsService.isTransitAgentForBooking( + user?.id, + entityId, + )) + ) { + throw new NotFoundException(`Entity ${entityId} not found`); + } + return { + userId: resolveAuthUserId(user), + name: actorLabel(user) ?? null, + side: 'TRANSIT', + }; + } + private actor(user: TCurrentUser): GlExchangeActor { const side: GlExchangeSide = !hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) && diff --git a/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts index 1b0d88b2a..e76b26efd 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-exchange.service.ts @@ -17,7 +17,7 @@ import type { FileRecord } from '../files/entities/file.entity'; */ export const GL_EXCHANGE_RESOURCE = 'gl_exchange'; -export type GlExchangeSide = 'ET' | 'DJ'; +export type GlExchangeSide = 'ET' | 'DJ' | 'TRANSIT'; export interface GlExchangeActor { userId: string; @@ -180,7 +180,14 @@ export class GlExchangeService { // Pre-title rows (none in practice) fall back to the filename so a list // never renders a blank row. title: record.title ?? record.name, - side: record.code === 'DJ' ? 'DJ' : 'ET', + // `files.code` carries the poster's side. Anything unrecognised reads as + // ET, which is how every pre-TRANSIT row was written. + side: + record.code === 'DJ' + ? 'DJ' + : record.code === 'TRANSIT' + ? 'TRANSIT' + : 'ET', visibleToCustomer: record.visibleToCustomer, uploadedById: record.uploadedByUserId, uploadedByName: record.uploadedByName, diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 6516ff9ce..70b42a9aa 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -12,6 +12,7 @@ import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; +import { ClearanceEventService } from '../bookings/clearance-event.service'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { @@ -55,6 +56,7 @@ export class GlOperationsService { private readonly milestoneService: ClearanceMilestoneService, private readonly billingService: BillingService, private readonly notifier: BookingLifecycleNotifierService, + private readonly clearanceEvents: ClearanceEventService, ) {} private get bookings() { @@ -362,13 +364,15 @@ export class GlOperationsService { } /** - * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass - * is secured on the train schedule (which itself follows wagon allocation). - * Replaces the previous batch; locked only once GL Ethiopia closes the T1. + * GL Djibouti / the transit agent uploads T1 transport documents (multi-file) + * once the train has DEPARTED Djibouti. Replaces the previous batch, so the + * batch's file stamps are always the last update; locked only once GL + * Ethiopia closes the T1. */ async uploadT1Documents( bookingId: string, files: Express.Multer.File[], + userId?: string, ): Promise<{ uploaded: number }> { const booking = await this.getBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -376,24 +380,24 @@ export class GlOperationsService { } const state = await this.t1State(bookingId); - if (!state.wagonAllocated) { + if (!state.trainDepartedAt) { throw new BadRequestException( - 'Wagons must be allocated before T1 transport documents can be uploaded.', - ); - } - const gatepass = await this.gatepassForBooking(bookingId); - if (!gatepass.granted) { - throw new BadRequestException( - 'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.', + 'T1 transport documents can be uploaded once the train has departed.', ); } if (state.closed) { throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.'); } - // Departure no longer locks T1 docs — GL DJ may replace them any time until - // GL Ethiopia closes/accepts the T1. await persistT1TransportUploads(this.filesService, bookingId, files); + // History row so the portal can tell a first upload from a replacement. + await this.clearanceEvents.record({ + bookingId, + action: 'T1_DOCUMENTS_UPLOADED', + label: `Uploaded T1 transport documents (${files.length} file(s))`, + actorId: userId ?? null, + metadata: { fileNames: files.map((f) => f.originalname) }, + }); return { uploaded: files.length }; } diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index ab2a2b2d4..05edb9cab 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -12,10 +12,17 @@ import { isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, + isGatePassFileCode, + isDjiboutiT1FileCode, exportTransportFileLabel, t1TransportFileLabel, transitPermitFileLabel, + gatePassFileLabel, + djiboutiT1FileLabel, + GATE_PASS_FILE_PREFIX, + DJIBOUTI_T1_FILE_PREFIX, type ClearanceWorkflowFile, + type TransitArrivalDocumentKind, } from '@edr/types'; /** Require at least one declaration file in the upload batch. */ @@ -46,6 +53,7 @@ type DeclarationFileStore = { resource: string; code: string; file: Express.Multer.File; + uploadedByUserId?: string | null; }): Promise; }; @@ -445,9 +453,88 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ /** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; +/** Prefix + matcher + label for each transit-agent arrival document set. */ +const TRANSIT_ARRIVAL_DOCUMENT_SETS: Record< + TransitArrivalDocumentKind, + { prefix: string; matches: (code: string | null | undefined) => boolean; label: (i?: number) => string } +> = { + gate_pass: { prefix: GATE_PASS_FILE_PREFIX, matches: isGatePassFileCode, label: gatePassFileLabel }, + djibouti_t1: { prefix: DJIBOUTI_T1_FILE_PREFIX, matches: isDjiboutiT1FileCode, label: djiboutiT1FileLabel }, +}; + +export function transitArrivalDocumentMatcher( + kind: TransitArrivalDocumentKind, +): (code: string | null | undefined) => boolean { + return TRANSIT_ARRIVAL_DOCUMENT_SETS[kind].matches; +} + +/** + * APPEND a batch of transit-agent arrival documents (gate pass / Djibouti T1) + * to a booking. Unlike the DO/RO persisters this never deletes what is already + * there: the officer collects these one at a time as the paperwork comes in, + * and each file is removed individually. Codes continue from the highest + * existing index so a removed file's slot is never reused. + */ +export async function persistTransitArrivalUploads( + store: DeclarationFileStore, + bookingId: string, + kind: TransitArrivalDocumentKind, + files: Express.Multer.File[], + uploadedByUserId?: string | null, +): Promise { + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind]; + const existing = await store.findByResource(bookingId, 'bookings'); + const nextIndex = + existing + .filter((f) => set.matches(f.code)) + .map((f) => Number.parseInt((f.code ?? '').slice(set.prefix.length), 10)) + .filter((n) => Number.isFinite(n)) + .reduce((max, n) => Math.max(max, n + 1), 0); + + await Promise.all( + files.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `${set.prefix}${nextIndex + index}`, + file: { ...file, fieldname: `${set.prefix}${nextIndex + index}` }, + uploadedByUserId: uploadedByUserId ?? null, + }), + ), + ); +} + +type WorkflowFileInput = { + code?: string | null; + id: string; + name: string; + url: string; + createdAt?: Date | string | null; + updatedAt?: Date | string | null; + size?: number | null; + mimeType?: string | null; +}; + +function toWorkflowFileRef(file: WorkflowFileInput): NonNullable { + const iso = (v: Date | string | null | undefined) => + v ? new Date(v).toISOString() : null; + return { + id: file.id, + name: file.name, + url: file.url, + uploadedAt: iso(file.createdAt), + updatedAt: iso(file.updatedAt), + size: file.size ?? null, + mimeType: file.mimeType ?? null, + }; +} + /** Build labeled phased-customs file rows from resource files. */ export function buildWorkflowFiles( - files: Array<{ code?: string | null; id: string; name: string; url: string }>, + files: WorkflowFileInput[], tradeDirection: string, ): ClearanceWorkflowFile[] { const fileByCode = new Map( @@ -465,7 +552,7 @@ export function buildWorkflowFiles( label: entry.label, uploadedBy: entry.uploadedBy, category: entry.category, - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); } @@ -481,7 +568,7 @@ export function buildWorkflowFiles( label: declarationFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'declaration', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -497,7 +584,7 @@ export function buildWorkflowFiles( label: draftDeclarationFileLabel(index), uploadedBy: 'gl_et', category: 'draft_declaration', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -514,7 +601,7 @@ export function buildWorkflowFiles( label: transitPermitFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -530,7 +617,7 @@ export function buildWorkflowFiles( label: deliveryOrderFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -546,7 +633,7 @@ export function buildWorkflowFiles( label: t1TransportFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); } @@ -564,7 +651,7 @@ export function buildWorkflowFiles( label: releaseOrderFileLabel(file.code, index), uploadedBy: 'gl_dj', category: 'djibouti', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); @@ -580,9 +667,32 @@ export function buildWorkflowFiles( label: exportTransportFileLabel(file.code, index), uploadedBy: 'gl_et', category: 'transit', - file: { id: file.id, name: file.name, url: file.url }, + file: toWorkflowFileRef(file), }); }); + + // Transit-agent arrival paperwork, ordered by slot index (upload order). + const byIndex = (prefix: string) => (a: WorkflowFileInput, b: WorkflowFileInput) => + Number.parseInt((a.code ?? '').slice(prefix.length), 10) - + Number.parseInt((b.code ?? '').slice(prefix.length), 10); + + for (const kind of ['gate_pass', 'djibouti_t1'] as const) { + const set = TRANSIT_ARRIVAL_DOCUMENT_SETS[kind]; + files + .filter((f) => f.code && set.matches(f.code) && !included.has(f.code)) + .sort(byIndex(set.prefix)) + .forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: set.label(index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: toWorkflowFileRef(file), + }); + }); + } } return out; diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts index 58c97619a..0b7de5be3 100644 --- a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => { {} as never, notifier as never, transitAgentsService as never, + { ensureAssignment: jest.fn() } as never, // transit assignments {} as never, // dataSource ); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts index 875349b6a..e8c111b62 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-bulk-registration.service.ts @@ -134,6 +134,7 @@ export class EimsBulkRegistrationService { region: invoice.company?.region, zone: invoice.company?.zone, woreda: invoice.company?.woreda, + kebele: invoice.company?.kebele, }); return { invoice, documentType, relatedDocument, buyerGeo }; }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts new file mode 100644 index 000000000..3d68e180e --- /dev/null +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.spec.ts @@ -0,0 +1,168 @@ +import { HttpService } from "@nestjs/axios"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AxiosError, AxiosHeaders } from "axios"; +import { of, throwError } from "rxjs"; + +import { EimsConfig } from "../../config/eims.config"; +import { EimsAuthService } from "./eims-auth.service"; +import { EimsClientService } from "./eims-client.service"; +import { EimsSignerService } from "./eims-signer.service"; +import { eimsConfig } from "./eims-test-fixtures"; + +const API_KEY = "super-secret-apikey"; +const CLIENT_SECRET = "super-secret-value"; +const TOKEN = "access-token-value"; + +/** Stub signer: the real signing path has its own spec and needs no key material here. */ +const signer = { + signRequest: (request: T) => ({ request, signature: "SIGNATURE", certificate: "CERTIFICATE" }), +} as unknown as EimsSignerService; + +const build = (post: jest.Mock, config: EimsConfig = eimsConfig(), token: string = TOKEN) => + new EimsClientService( + { post } as unknown as HttpService, + { get: () => config } as unknown as ConfigService, + { + getValidAccessToken: jest.fn().mockResolvedValue(token), + invalidate: jest.fn(), + } as unknown as EimsAuthService, + signer, + ); + +const ok = (data: unknown = { statusCode: 200, body: { Irn: "irn-echoed" } }) => + jest.fn().mockReturnValue(of({ data })); + +const axiosErr = (status: number, data: unknown) => + new AxiosError("Request failed", undefined, undefined, undefined, { + status, + statusText: "", + data, + headers: new AxiosHeaders(), + config: { headers: new AxiosHeaders() }, + }); + +/** `post(url, body, config)` — the config argument every assertion below reads. */ +const sentConfig = (post: jest.Mock, call = 0) => post.mock.calls[call][2]; +const sentBody = (post: jest.Mock, call = 0) => post.mock.calls[call][1]; + +describe("EimsClientService transport", () => { + const protectedHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${TOKEN}`, + apikey: API_KEY, + }; + + it.each([ + ["verify", "/v1/verify", { irn: "irn-1" }], + ["sales receipt", "/v1/receipt/sales", { receipt: "sales" }], + ["withholding receipt", "/v1/receipt/withholding", { receipt: "withholding" }], + ["cancel", "/v1/cancel", { Irn: "irn-1" }], + ["bulk cancel", "/v1/bulkCancel", [{ Irn: "irn-1" }]], + ])("authenticates the raw %s endpoint without changing its body", async (_name, path, body) => { + const post = ok(); + await build(post).postBearer(path, body); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(sentBody(post)).toBe(body); + }); + + it.each([ + ["invoice", { DocumentDetails: { Type: "INV" } }], + ["credit memo", { DocumentDetails: { Type: "CRE" } }], + ["debit memo", { DocumentDetails: { Type: "DEB" } }], + ])("authenticates and signs a %s registration", async (_name, request) => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", request); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("authenticates bulk registration through the same signed path", async () => { + const post = ok({ conversationId: "conversation-1", status: 202 }); + const request = [{ DocumentDetails: { Type: "INV" } }]; + await build(post).postSigned("/v1/bulkRegister", request); + + expect(sentConfig(post).headers).toEqual(protectedHeaders); + }); + + it("wraps a signed call in the {request,signature,certificate} envelope", async () => { + const post = ok({ statusCode: 200, body: { irn: "irn-1" } }); + await build(post).postSigned("/v1/register", { Invoice: 1 }); + + expect(JSON.parse(sentBody(post) as string)).toEqual({ + request: { Invoice: 1 }, + signature: "SIGNATURE", + certificate: "CERTIFICATE", + }); + }); + + it("leaves an unsigned body verbatim", async () => { + const post = ok(); + await build(post).postBearer("/v1/cancel", { Irn: "irn-1" }); + + // Raw object, not the JSON string `toSignedBody` produces. + expect(sentBody(post)).toEqual({ Irn: "irn-1" }); + }); + + it("re-authenticates a raw verify call through the one 401 retry without changing its body", async () => { + const post = jest + .fn() + .mockReturnValueOnce(throwError(() => axiosErr(401, { message: "expired" }))) + .mockReturnValueOnce(of({ data: { statusCode: 200, body: { Irn: "irn-1" } } })); + + await build(post).postBearer("/v1/verify", { irn: "irn-1" }); + + expect(post).toHaveBeenCalledTimes(2); + expect(sentConfig(post, 1).headers.Authorization).toBe(`Bearer ${TOKEN}`); + expect(sentBody(post, 1)).toEqual({ irn: "irn-1" }); + }); + + it("never leaks the api key, bearer token or client secret into a thrown failure", async () => { + const logError = jest.spyOn(Logger.prototype, "error").mockImplementation(() => undefined); + const post = jest.fn().mockReturnValue( + throwError(() => + // A gateway rejection may echo request data; redaction must remove it before logging. + axiosErr(400, { + message: "GATEWAY ERROR", + code: "4001", + details: [ + { field: "certificate", errorMessage: "must not be null" }, + { field: "signature", errorMessage: "must not be null" }, + { field: "request", errorMessage: "must not be null" }, + ], + // An echoed request is exactly what redaction has to drop. + request: { apikey: API_KEY, clientSecret: CLIENT_SECRET }, + }), + ), + ); + + const error: Error = await build(post) + .postBearer("/v1/verify", { irn: "irn-1" }) + .then(() => { + throw new Error("expected the call to reject"); + }) + .catch((err: Error) => err); + + const serialized = JSON.stringify({ + message: error.message, + response: (error as { getResponse?: () => unknown }).getResponse?.(), + details: (error as { details?: unknown }).details, + }); + expect(serialized).not.toContain(API_KEY); + expect(serialized).not.toContain(CLIENT_SECRET); + expect(serialized).not.toContain(TOKEN); + const serializedLogs = JSON.stringify(logError.mock.calls); + expect(serializedLogs).not.toContain(API_KEY); + expect(serializedLogs).not.toContain(CLIENT_SECRET); + expect(serializedLogs).not.toContain(TOKEN); + // The gateway's own reporting still survives redaction. + expect(error.message).toContain("4001"); + logError.mockRestore(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts index 610647b1a..4f2455c85 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-client.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-client.service.ts @@ -8,10 +8,10 @@ import { EimsSignerService, toSignedBody } from "./eims-signer.service"; import { toEimsApiException } from "./eims.errors"; /** - * Foundation for EIMS's bearer-authenticated endpoints (`/v1/register`, `/v1/verify`, …). + * Foundation for EIMS's authenticated endpoints (`/v1/register`, `/v1/verify`, …). * * Login is not routed through here: `/auth/login` carries no bearer token and lives in - * `EimsAuthService`. Nothing calls `postSigned` yet — invoice registration is a later phase. + * `EimsAuthService`. */ @Injectable() export class EimsClientService { @@ -29,7 +29,8 @@ export class EimsClientService { } /** - * Sign `request`, POST it to `path` with a valid bearer token, and return the parsed response. + * Sign `request`, POST it to `path` with the shared protected-endpoint headers, and return the + * parsed response. * A 401 invalidates the cached token and retries exactly once. */ async postSigned(path: string, request: TRequest): Promise { @@ -37,12 +38,8 @@ export class EimsClientService { } /** - * POST `request` verbatim — bearer-authenticated but **not** wrapped in a signed envelope. - * - * `/v1/verify` is the only endpoint observed to work this way: the supplied collection sends a - * raw `{"irn":"…"}` body with no `signature`/`certificate` siblings. Kept as its own entry point - * so that if the live gateway turns out to require signing after all, exactly one call site - * changes — `postSigned` is already the alternative. + * POST `request` verbatim with the shared protected-endpoint headers, but **not** wrapped in a + * signed envelope. This is the wire contract for verify, cancel and receipt calls. */ async postBearer(path: string, request: TRequest): Promise { return this.send(path, request, false, false); @@ -61,7 +58,11 @@ export class EimsClientService { try { const res = await firstValueFrom( this.http.post(`${cfg.baseUrl}${path}`, body, { - headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + apikey: cfg.apiKey, + }, timeout: cfg.httpTimeoutMs, }), ); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts index 2fb83dc72..030cf2fe5 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-context.ts @@ -3,6 +3,7 @@ import { EimsConfig } from "../../config/eims.config"; import { MorGeoCodes } from "../../config/mor-location.resolver"; import { EimsSessionContext } from "./eims-auth.service"; import { + EimsLineTax, EimsMapperContext, EimsMapperLine, EimsSellerDetails, @@ -172,12 +173,35 @@ export interface EimsContextInput { relatedDocument?: string | null; } +/** + * Tax treatment of one charge type: its per-`chargeType` override when one is configured + * (validated symmetric in `assertChargeTypeOverrides`), else the single invoice-wide default. + * + * Exported because the printed tax document has to state the same Tax Code, Excise and Discount + * per line that was filed with MoR, and it must be able to do so without a live EIMS session — + * `buildEimsContext` needs a system number from an access token, printing does not. + */ +export function resolveLineTax(config: EimsConfig, chargeType: string): EimsLineTax { + const { invoice } = config; + return { + code: invoice.taxCodeByChargeType[chargeType] ?? invoice.taxCode, + ratePercent: + chargeType in invoice.taxRateByChargeType + ? Number(invoice.taxRateByChargeType[chargeType]) + : invoice.taxRatePercent!, + exciseTaxValue: + chargeType in invoice.exciseByChargeType + ? Number(invoice.exciseByChargeType[chargeType]) + : (invoice.exciseTaxValue ?? 0), + discount: + chargeType in invoice.discountByChargeType + ? Number(invoice.discountByChargeType[chargeType]) + : 0, + }; +} + export function buildEimsContext(config: EimsConfig, input: EimsContextInput): EimsMapperContext { const { invoice } = config; - // Validated by assertEimsInvoiceConfig; the non-null assertions below are safe after that call. - const taxCode = invoice.taxCode; - const ratePercent = invoice.taxRatePercent!; - const exciseTaxValue = invoice.exciseTaxValue ?? 0; return { systemNumber: input.session.systemNumber, @@ -191,23 +215,7 @@ export function buildEimsContext(config: EimsConfig, input: EimsContextInput): E payment: { mode: invoice.paymentMode, term: invoice.paymentTerm }, // Per-`chargeType` override when one is configured (validated symmetric in // assertChargeTypeOverrides), else the single invoice-wide default. - taxForLine: (line: EimsMapperLine) => { - const { chargeType } = line; - const code = invoice.taxCodeByChargeType[chargeType] ?? taxCode; - const rate = - chargeType in invoice.taxRateByChargeType - ? Number(invoice.taxRateByChargeType[chargeType]) - : ratePercent; - const excise = - chargeType in invoice.exciseByChargeType - ? Number(invoice.exciseByChargeType[chargeType]) - : exciseTaxValue; - const discount = - chargeType in invoice.discountByChargeType - ? Number(invoice.discountByChargeType[chargeType]) - : 0; - return { code, ratePercent: rate, exciseTaxValue: excise, discount }; - }, + taxForLine: (line: EimsMapperLine) => resolveLineTax(config, line.chargeType), natureOfSupplies: invoice.natureOfSupplies, unitDefault: invoice.unitDefault, incomeWithholdValue: invoice.incomeWithholdValue!, diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts index 69a4d5ffd..34e654e3b 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.spec.ts @@ -759,16 +759,15 @@ describe("EimsInvoiceRegistrationService staff alerting", () => { }); describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { - it("verifies the stored IRN over the unsigned bearer transport", async () => { + it("verifies the stored IRN as an unchanged raw body", async () => { const db = new FakeDb([invoiceRow({ eimsIrn: IRN })]); - const postSigned = jest.fn(); const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn(); const result = await build(db, postSigned, config(), postBearer).verifyInvoiceWithEims( INVOICE_ID, ); - // Lowercase `irn`, raw body — not a signed envelope. `postSigned` must stay untouched. expect(postBearer).toHaveBeenCalledWith("/v1/verify", { irn: IRN }); expect(postSigned).not.toHaveBeenCalled(); expect(result.body).toMatchObject({ Irn: IRN }); @@ -792,6 +791,27 @@ describe("EimsInvoiceRegistrationService.verifyInvoiceWithEims", () => { ).rejects.toThrow(/no EIMS IRN to verify/); expect(postBearer).not.toHaveBeenCalled(); }); + + it("leaves a filed invoice and the IRN chain untouched when the gateway rejects the verify", async () => { + const db = new FakeDb([ + invoiceRow({ eimsIrn: IRN, eimsStatus: EimsInvoiceStatus.Registered }), + ]); + const before = { ...db.invoices.get(INVOICE_ID)! }; + const stateBefore = { ...db.state! }; + // The live failure this guards: `GATEWAY ERROR code=4001`, a transport fault on a document + // that is already registered. Verification is a read — a failed read must never downgrade the + // registration or move the counter. + const postBearer = jest + .fn() + .mockRejectedValue(new EimsApiException("SCHEMA_VALIDATION", "GATEWAY ERROR code=4001", 400)); + + await expect( + build(db, jest.fn(), config(), postBearer).verifyInvoiceWithEims(INVOICE_ID), + ).rejects.toThrow(/4001/); + + expect(db.invoices.get(INVOICE_ID)).toEqual(before); + expect(db.state).toEqual(stateBefore); + }); }); describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { @@ -886,15 +906,15 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { it("discards the attempt, leaving the chain where it was", async () => { const db = blocked(); - const postBearer = jest.fn(); + const postSigned = jest.fn(); - const view = await build(db, jest.fn(), config(), postBearer).resolveEimsRegistration( + const view = await build(db, postSigned, config(), jest.fn()).resolveEimsRegistration( INVOICE_ID, { discard: true }, ); expect(view).toMatchObject({ eimsStatus: EimsInvoiceStatus.Failed, eimsIrn: null }); - expect(postBearer).not.toHaveBeenCalled(); // nothing to confirm + expect(postSigned).not.toHaveBeenCalled(); // nothing to confirm expect(db.state).toMatchObject({ previousIrn: null, inFlightInvoiceId: null, @@ -908,10 +928,10 @@ describe("EimsInvoiceRegistrationService.resolveEimsRegistration", () => { OTHER_INVOICE_ID, invoiceRow({ id: OTHER_INVOICE_ID, eimsDocumentNumber: "6" }), ); - const postBearer = jest.fn().mockResolvedValue(verifyResponse()); + const postSigned = jest.fn().mockResolvedValue(verifyResponse()); await expect( - build(db, jest.fn(), config(), postBearer).resolveEimsRegistration(OTHER_INVOICE_ID, { + build(db, postSigned, config(), jest.fn()).resolveEimsRegistration(OTHER_INVOICE_ID, { irn: IRN, }), ).rejects.toThrow(/in-flight EIMS submission is invoice/); diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts index d5c987779..246914e1c 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice-registration.service.ts @@ -126,6 +126,7 @@ export class EimsInvoiceRegistrationService { region: invoice.company?.region, zone: invoice.company?.zone, woreda: invoice.company?.woreda, + kebele: invoice.company?.kebele, }); // Authenticate before reserving: the source system comes from the token, and the state row is @@ -213,7 +214,8 @@ export class EimsInvoiceRegistrationService { * compared — the supplied collection's own fixture uses different example values on each side, * so equality there would assert a property of the mock rather than of the gateway. * - * Bearer-authenticated but unsigned, via `postBearer` — see that method for why. + * Raw, via `postBearer`: verification accepts exactly `{"irn":"…"}` and relies on the shared + * transport for the bearer token and API-key header. It must not be signed or wrapped. */ private async queryVerify(irn: string): Promise { const response = await this.client.postBearer( diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts index 841bfcc54..0d4a0fd78 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt-document.mapper.ts @@ -1,8 +1,11 @@ +import { EimsConfig } from "../../config/eims.config"; import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceDocumentModel, + MorPartyDetails, pngDataUrl, } from "../billing/documents/invoice-document.service"; +import { buildEimsSeller } from "./eims-invoice-context"; import { EimsReceipt, EimsReceiptStatus } from "./entities/eims-receipt.entity"; import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-receipt.types"; @@ -20,7 +23,11 @@ import { EimsSalesReceiptRequest, EimsWithholdReceiptRequest } from "./eims-rece * would read as a genuine tax document. Callers (`EimsReceiptService.document`) let this throw * surface as a 400 — there is nothing sensible to render instead. */ -export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): InvoiceDocumentModel { +export function toReceiptDocumentModel( + receipt: EimsReceipt, + invoice: Invoice, + config?: EimsConfig, +): InvoiceDocumentModel { if (receipt.status !== EimsReceiptStatus.Registered) { throw new Error( `Receipt ${receipt.receiptNumber} is ${receipt.status}, not REGISTERED — refusing to print an unfiled receipt.`, @@ -45,6 +52,34 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): // if that default changes for an unrelated reason. sealText: "EDR PAID", extraSummary: [{ label: "Mode of payment", value: req.TransactionDetails.ModeOfPayment }], + mor: config?.invoice + ? { + titleAm: "የገንዘብ መቀበያ ደረሰኝ", + titleEn: "Cash Receipt Voucher", + saleType: config.invoice.transactionType, + systemNumber: req.SourceSystemNumber || config.systemNumber || null, + ...parties(config, invoice), + payment: { + mode: req.TransactionDetails.ModeOfPayment, + typeMethod: config.invoice.paymentTerm, + receiverName: invoice.company?.name ?? null, + }, + receipt: { + rrn: receipt.rrn ?? "", + reason: req.Reason, + collectedAmount: req.CollectedAmount, + // One row per invoice the payment covers — MoR's receipt is invoice-linked, so the + // printed voucher has to show which document(s) the money was applied to. + invoices: req.Invoices.map((line) => ({ + irn: line.InvoiceIRN, + paymentCoverage: line.PaymentCoverage, + totalAmount: line.TotalAmount, + remainingAmount: line.RemainingAmount ?? 0, + paidAmount: line.InvoicePaidAmount, + })), + }, + } + : null, }); } @@ -59,9 +94,63 @@ export function toReceiptDocumentModel(receipt: EimsReceipt, invoice: Invoice): // wrong here, so this is the one case that MUST override it. sealText: "EDR", extraSummary: [{ label: "Withholding type", value: req.WithholdDetail.Type }], + mor: config?.invoice + ? { + titleAm: "ከተከፋይ ሒሳብ ላይ ለተቀነሰ ግብር የተሰጠ ደረሰኝ", + titleEn: "Withholding tax on payment", + ...parties(config, invoice), + systemNumber: req.SourceSystemNumber || config.systemNumber || null, + withholding: { + receiptNumber: receipt.receiptNumber, + counter: req.ReceiptCounter, + reason: req.Reason, + type: req.WithholdDetail.Type, + invoiceCurrency: req.InvoiceDetail.Currency, + preTaxAmount: req.WithholdDetail.PreTaxAmount, + withheldAmount: req.WithholdDetail.WithholdingAmount, + systemType: req.SourceSystemType, + systemNumber: req.SourceSystemNumber || config.systemNumber || "", + }, + } + : null, }); } +/** + * `ከ / From` and `ለ / To` for a receipt. On a withholding receipt the seller is the withholding + * agent and the buyer the taxpayer, which is the same pair of blocks in the same order — the + * layout relabels them, so the mapping does not change. + */ +function parties( + config: EimsConfig, + invoice: Invoice, +): { seller: MorPartyDetails; buyer: MorPartyDetails } { + const seller = buildEimsSeller(config); + const company = invoice.company; + return { + seller: { + name: config.invoice.sellerLegalName || seller.LegalName, + city: seller.City, + subCity: seller.SubCity, + woreda: seller.Wereda, + kebele: seller.Locality, + houseNo: seller.HouseNumber, + tin: seller.Tin, + vatNumber: seller.VatNumber, + }, + buyer: { + name: company?.name ?? "N/A", + city: company?.zone ?? null, + subCity: company?.zone ?? null, + woreda: company?.woreda ?? null, + kebele: company?.kebele ?? null, + houseNo: company?.houseNo ?? null, + tin: company?.tin ?? null, + vatNumber: company?.vatNumber ?? null, + }, + }; +} + function build( receipt: EimsReceipt, invoice: Invoice, @@ -73,6 +162,7 @@ function build( amount: number; sealText: string; extraSummary: Array<{ label: string; value: string | null }>; + mor?: InvoiceDocumentModel["mor"]; }, ): InvoiceDocumentModel { return { diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts index 55a38480b..99af6ef30 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.spec.ts @@ -191,6 +191,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { it("marks the receipt FAILED on a deterministic rejection and rethrows", async () => { const db = new FakeDb([invoiceRow()]); + const invoiceBefore = { ...db.invoices.get(INVOICE_ID)! }; const postBearer = jest .fn() .mockRejectedValue(new EimsApiException("RULE_VALIDATION", "EIMS receipt failed (406)", 406)); @@ -200,6 +201,7 @@ describe("EimsReceiptService.registerSalesReceipt", () => { ).rejects.toBeInstanceOf(EimsApiException); const [receipt] = [...db.receipts.values()]; expect(receipt.status).toBe(EimsReceiptStatus.Failed); + expect(db.invoices.get(INVOICE_ID)).toEqual(invoiceBefore); }); it("marks the receipt UNKNOWN on an ambiguous failure (never auto-retried)", async () => { diff --git a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts index f2de2937c..8c98d0293 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-receipt.service.ts @@ -196,7 +196,7 @@ export class EimsReceiptService { let model: ReturnType; try { - model = toReceiptDocumentModel(receipt, invoice); + model = toReceiptDocumentModel(receipt, invoice, this.cfg); } catch (err) { // Only the mapper's own refusals (not-yet-registered, missing request body) become a 400 — // a genuine PDF-render failure below is left to surface as whatever InvoiceDocumentService diff --git a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts index 7afe644a1..ca5cb6a30 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-seller-cache.service.ts @@ -116,6 +116,7 @@ export class EimsSellerCacheService implements OnModuleInit { region: data.region, zone: data.zone, woreda: data.woreda, + kebele: data.kebele, }); this.cached = { // The *legal* entity name, not the licence's trade name that diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts new file mode 100644 index 000000000..c588a5b03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateEmptyReturnRequestDto { + @ApiProperty({ description: 'Booking the empties came in on.' }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ + type: [String], + description: + 'One container number per empty being returned — the customer types as many as they said they are sending back.', + example: ['TEMU1234567', 'MSCU7654321'], + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + @MinLength(4, { each: true }) + @MaxLength(64, { each: true }) + containerNumbers!: string[]; +} + +export class ApproveEmptyReturnRequestDto { + @ApiPropertyOptional({ + description: + 'Per-container price to bill. Defaults to the route WITH_RETURN rate the quote was built from.', + }) + @IsOptional() + @IsNumber() + @IsPositive() + unitAmount?: number; + + @ApiPropertyOptional({ + description: 'Currency of `unitAmount`. Defaults to the quote currency (ETB).', + }) + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; +} + +export class RejectEmptyReturnRequestDto { + @ApiProperty({ description: 'Why the request was turned down — shown to the customer.' }) + @IsString() + @MinLength(3) + reason!: string; +} + +export class ScheduleEmptyReturnRequestDto { + @ApiProperty({ + description: 'The day the customer will hand the empties over.', + example: '2026-09-20', + }) + @IsDateString() + returnDate!: string; + + @ApiProperty({ description: 'Plate of the truck bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(32) + truckPlateNumber!: string; + + @ApiProperty({ description: 'Driver bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(120) + truckDriverName!: string; + + @ApiPropertyOptional({ description: 'Truck type (flatbed, container chassis…).' }) + @IsOptional() + @IsString() + @MaxLength(60) + truckType?: string; +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts new file mode 100644 index 000000000..06687253b --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts @@ -0,0 +1,137 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity'; + +/** + * Reading the queue is OR'd with the warehouse-inventory key the rest of the + * Imports menu uses, so the staff who already run container returns can open + * it while the dedicated key is still being handed out. Approving and + * rejecting stay on the review key alone — that one is a commercial decision. + */ +const CAN_VIEW = [ + FREIGHT_PERMS.emptyReturnRequests.view, + FREIGHT_PERMS.warehouseInventory.view, +]; + +@ApiTags('empty-return-requests') +@ApiBearerAuth() +@Controller('empty-return-requests') +export class EmptyReturnRequestsController { + constructor(private readonly service: EmptyReturnRequestsService) {} + + @Get() + @BookingStaff(CAN_VIEW) + @ApiOperation({ summary: 'Empty container return requests queue' }) + findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) { + return this.service.findAll({ + status: status as EmptyReturnRequestStatus | undefined, + bookingId, + }); + } + + @Get('planned') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ + summary: 'Scheduled empty returns the warehouse is expecting, with date and truck', + }) + planned() { + return this.service.plannedReturns(); + } + + @Get('eligibility/:bookingId') + @MixedAudience(CAN_VIEW) + @ApiOperation({ + summary: + 'Whether a booking may request an empty return, its free containers, and the price per container', + }) + eligibility( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.eligibility(bookingId, this.portalUserId(user)); + } + + @Get('by-booking/:bookingId') + @MixedAudience(CAN_VIEW) + @ApiOperation({ summary: "A booking's empty return requests, newest first" }) + findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.service.findForBooking(bookingId); + } + + @Get(':id') + @MixedAudience(CAN_VIEW) + @ApiOperation({ summary: 'Get an empty return request by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.service.findById(id, this.portalUserId(user)); + } + + @Post() + @PortalCustomer() + @ApiOperation({ + summary: 'Customer requests to return empty containers on a booking sold without return', + }) + create(@Body() dto: CreateEmptyReturnRequestDto, @CurrentUser() user: TCurrentUser) { + return this.service.create(dto, user?.id ?? null); + } + + @Post(':id/schedule') + @PortalCustomer() + @ApiOperation({ + summary: 'Customer sets the return date and the truck bringing the empties back', + }) + schedule( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ScheduleEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.schedule(id, user?.id ?? null, dto); + } + + @Post(':id/approve') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ + summary: + 'Approve and bill the request — the price defaults to the route WITH_RETURN rate per container', + }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id ?? null, dto); + } + + @Post(':id/reject') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ summary: 'Reject the request with a reason shown to the customer' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id ?? null, dto); + } + + /** + * Staff read any booking's request; a customer is held to their own. Passing + * the user id is what turns the ownership check on, so staff pass null. + */ + private portalUserId(user: TCurrentUser): string | null { + if (hasFreightPermission(user, FREIGHT_PERMS.emptyReturnRequests.review)) return null; + return user?.id ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts new file mode 100644 index 000000000..f682f80e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts @@ -0,0 +1,27 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; +import { BillingModule } from '../billing/billing.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { EmptyReturnRequest } from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsController } from './empty-return-requests.controller'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([EmptyReturnRequest]), + BillingModule, + forwardRef(() => BookingsModule), + NotificationInboxModule, + RuleEngineModule, + registerExchangeModule(), + ], + controllers: [EmptyReturnRequestsController], + providers: [EmptyReturnRequestsRepository, EmptyReturnRequestsService], + exports: [EmptyReturnRequestsService], +}) +export class EmptyReturnRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts new file mode 100644 index 000000000..067c869ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +@Injectable() +export class EmptyReturnRequestsRepository extends BaseRepository { + constructor( + @InjectRepository(EmptyReturnRequest) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts new file mode 100644 index 000000000..5b358b7aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts @@ -0,0 +1,413 @@ +import { BadRequestException } from '@nestjs/common'; + +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +/** + * The service is mostly gates and pricing over raw SQL, so the SQL is stubbed + * by matching a distinctive fragment of each statement. Every stub returns the + * shape the real query returns. + */ +type QueryStub = Array<[string, unknown]>; + +const booking = { + id: 'b1', + reference: 'BK-2026-000300', + companyId: 'co1', + companyProfileId: 'cp1', + status: 'ARRIVED', + freightType: 'CONTAINER', + equipmentReturn: 'WITHOUT_RETURN', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + paymentCurrency: 'ETB', +}; + +function build( + overrides: { + booking?: Partial; + request?: Partial; + rates?: unknown[]; + queries?: QueryStub; + } = {}, +) { + const merged = { ...booking, ...overrides.booking }; + + const requestRow: EmptyReturnRequest = { + id: 'r1', + bookingId: merged.id, + companyId: merged.companyId, + status: 'SUBMITTED', + containerNumbers: ['TEMU1111111', 'TEMU2222222', 'TEMU3333333'], + containerCount: 3, + submittedAt: new Date(), + ...overrides.request, + } as EmptyReturnRequest; + + const stubs: QueryStub = [ + ['FROM freight.booking_container\n', [{ containerTypeId: 'ct-40' }]], + [ + 'upper(bcu.container_number)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ['COALESCE(SUM(quantity), 0)', [{ quantity: '5' }]], + ['unnest(r.container_numbers)', []], + ['COUNT(*) AS outstanding', [{ outstanding: '0' }]], + ...(overrides.queries ?? []), + ]; + + const query = jest.fn(async (sql: string) => { + // Later stubs win, so a test can override one of the defaults. + for (let i = stubs.length - 1; i >= 0; i -= 1) { + if (sql.includes(stubs[i][0])) return stubs[i][1]; + } + return []; + }); + + const requests = { + findById: jest.fn(async () => requestRow), + findAll: jest.fn(async () => [requestRow]), + create: jest.fn(async (data: Partial) => ({ ...requestRow, ...data })), + update: jest.fn(async () => requestRow), + }; + const bookingsService = { + findById: jest.fn(async () => merged), + assertCustomerCanAccessBooking: jest.fn(async () => undefined), + }; + const billing = { generateInvoice: jest.fn(async () => ({ id: 'inv1' })) }; + const notifications = { notify: jest.fn(async () => undefined) }; + const ratesService = { + findLiveRatesDetailed: jest.fn( + async () => + overrides.rates ?? [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: 'ct-40', + rateValue: '100', + }, + ], + ), + }; + const exchange = { getRate: jest.fn(async () => 120) }; + + const service = new EmptyReturnRequestsService( + requests as never, + { findById: jest.fn(async () => merged) } as never, + bookingsService as never, + billing as never, + notifications as never, + ratesService as never, + exchange as never, + { query } as never, + ); + + return { + service, + requests, + bookingsService, + billing, + notifications, + query, + requestRow, + booking: merged, + }; +} + +describe('EmptyReturnRequestsService — eligibility', () => { + it('lets an arrived container booking sold without return ask for one', async () => { + const { service } = build(); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(true); + expect(result.reason).toBeNull(); + expect(result.availableContainerNumbers).toEqual(['TEMU1111111', 'TEMU2222222']); + }); + + it('refuses bulk freight — there is no equipment to give back', async () => { + const { service } = build({ booking: { freightType: 'BULK' } }); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/container freight only/i); + }); + + it('refuses a booking that already bought the return service', async () => { + const withReturn = build({ booking: { equipmentReturn: 'WITH_RETURN' } }); + const legacy = build({ booking: { equipmentReturn: 'RETURN' } }); + + expect((await withReturn.service.eligibility('b1', null)).reason).toMatch( + /already ships with/i, + ); + expect((await legacy.service.eligibility('b1', null)).reason).toMatch(/already ships with/i); + }); + + it('refuses a booking that has not shipped yet', async () => { + const { service } = build({ booking: { status: 'PAID' } }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/once the booking is in transit/i); + }); + + it('allows it after delivery, when the empty actually comes back', async () => { + const { service } = build({ booking: { status: 'COMPLETED' } }); + expect((await service.eligibility('b1', null)).eligible).toBe(true); + }); + + it('refuses when every container is already on a request', async () => { + const { service } = build({ + queries: [ + [ + 'unnest(r.container_numbers)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ], + }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/already on an empty return request/i); + }); + + it('refuses a booking with no container numbers to pick from', async () => { + const { service } = build({ queries: [['upper(bcu.container_number)', []]] }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/no container numbers are recorded/i); + expect(result.availableContainerNumbers).toEqual([]); + }); + + it('checks booking ownership for a portal caller, and skips it for staff', async () => { + const portal = build(); + await portal.service.eligibility('b1', 'user1'); + expect(portal.bookingsService.assertCustomerCanAccessBooking).toHaveBeenCalled(); + + const staff = build(); + await staff.service.eligibility('b1', null); + expect(staff.bookingsService.assertCustomerCanAccessBooking).not.toHaveBeenCalled(); + }); +}); + +describe('EmptyReturnRequestsService — creating a request', () => { + it('accepts containers that came in on the booking', async () => { + const { service, requests } = build(); + await service.create( + { bookingId: 'b1', containerNumbers: ['temu1111111', 'TEMU2222222'] }, + 'user1', + ); + + expect(requests.create).toHaveBeenCalledWith( + expect.objectContaining({ + bookingId: 'b1', + containerNumbers: ['TEMU1111111', 'TEMU2222222'], + containerCount: 2, + status: 'SUBMITTED', + }), + ); + }); + + it('refuses a container that is not on the booking', async () => { + const { service, requests } = build(); + + await expect( + service.create( + { bookingId: 'b1', containerNumbers: ['TEMU1111111', 'MSCU9999999'] }, + 'user1', + ), + ).rejects.toThrow(/Not on booking BK-2026-000300: MSCU9999999/); + expect(requests.create).not.toHaveBeenCalled(); + }); + + it('refuses the same container twice', async () => { + const { service } = build(); + + await expect( + service.create( + { bookingId: 'b1', containerNumbers: ['TEMU1111111', 'TEMU1111111'] }, + 'user1', + ), + ).rejects.toThrow(/selected twice/i); + }); + + it('refuses a container already sitting on a live request', async () => { + const { service } = build({ + queries: [['unnest(r.container_numbers)', [{ containerNumber: 'TEMU1111111' }]]], + }); + + await expect( + service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'), + ).rejects.toThrow(/Already on an empty return request/); + }); + + it('refuses a booking that already ships with return', async () => { + const { service } = build({ booking: { equipmentReturn: 'WITH_RETURN' } }); + + await expect( + service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe('EmptyReturnRequestsService — pricing', () => { + it('prices a container at the route WITH_RETURN rate, converted to birr', async () => { + const { service, booking: b } = build(); + const quote = await service.quote(b as never); + + // 100 USD × 120 ETB/USD + expect(quote).toMatchObject({ unitAmount: 12000, currency: 'ETB', sourceRateUsd: 100 }); + expect(quote.unavailableReason).toBeNull(); + }); + + it('falls back to the route rate that names no container type', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + + expect((await service.quote(b as never)).unitAmount).toBe(9600); + }); + + it('reports no price when no rate covers the route', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'EXPORT', + originYardId: 'other', + destinationYardId: 'other', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + const quote = await service.quote(b as never); + + expect(quote.unitAmount).toBeNull(); + expect(quote.unavailableReason).toMatch(/no empty-return rate/i); + }); +}); + +describe('EmptyReturnRequestsService — approval', () => { + it('bills container count × the route rate and stores the invoice', async () => { + const { service, billing, requests } = build(); + await service.approve('r1', 'staff1', {}); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'empty_return_request', + sourceId: 'r1', + currency: 'ETB', + totalAmount: 36000, // 3 × 12,000 + }), + ); + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'APPROVED', + quotedUnitAmount: 12000, + quotedTotalAmount: 36000, + invoiceId: 'inv1', + }), + ); + }); + + it("bills the reviewer's override instead of the route rate", async () => { + const { service, billing } = build(); + await service.approve('r1', 'staff1', { unitAmount: 5000 }); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ totalAmount: 15000 }), + ); + }); + + it('refuses to approve without a price when no rate covers the route', async () => { + const { service } = build({ rates: [] }); + await expect(service.approve('r1', 'staff1', {})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('only approves a submitted request', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.approve('r1', 'staff1', {})).rejects.toThrow(/Only a submitted request/); + }); +}); + +describe('EmptyReturnRequestsService — scheduling', () => { + const details = { + returnDate: '2026-09-20', + truckPlateNumber: '3-a12345', + truckDriverName: 'Abebe K.', + }; + + it('takes the date and truck once the invoice is paid', async () => { + const { service, requests } = build({ request: { status: 'PAID' } }); + await service.schedule('r1', 'user1', details); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'SCHEDULED', + requestedReturnDate: '2026-09-20', + truckPlateNumber: '3-A12345', + }), + ); + }); + + it('tells an unpaid customer to pay first', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.schedule('r1', 'user1', details)).rejects.toThrow( + /Pay the empty return invoice/, + ); + }); +}); + +describe('EmptyReturnRequestsService — payment and completion', () => { + it('moves an approved request to PAID when its invoice settles', async () => { + const { service, requests } = build({ request: { status: 'APPROVED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'PAID' })); + }); + + it('ignores a settlement for a request that is not awaiting payment', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).not.toHaveBeenCalled(); + }); + + it('completes a scheduled request once every container is recorded back', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ status: 'COMPLETED' }), + ); + }); + + it('leaves it scheduled while any container is still outstanding', async () => { + const { service, requests } = build({ + request: { status: 'SCHEDULED' }, + queries: [['COUNT(*) AS outstanding', [{ outstanding: '2' }]]], + }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts new file mode 100644 index 000000000..aae59de39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts @@ -0,0 +1,617 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource } from 'typeorm'; + +import { ExchangeService } from '@edr/api-common'; +import { Freight, NotificationAudience, NotificationPriority, NotificationType } from '@edr/types'; + +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BillingService } from '../billing/billing.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { + EmptyReturnRequest, + type EmptyReturnRequestStatus, +} from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; + +/** The invoice `source` this module owns — also the `${source}.invoice.paid` event prefix. */ +const INVOICE_SOURCE = 'empty_return_request'; + +/** + * Booking statuses that may still ask for an empty return. The empty only goes + * back after the cargo is delivered, so everything from departure onward + * qualifies — cutting it off at ARRIVED would take the option away exactly + * when the customer needs it. + */ +const REQUESTABLE_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED']; + +/** Requests that still hold their container numbers — a rejected one releases them. */ +const OPEN_STATUSES: EmptyReturnRequestStatus[] = [ + 'SUBMITTED', + 'APPROVED', + 'PAID', + 'SCHEDULED', + 'COMPLETED', +]; + +export interface EmptyReturnQuote { + /** Per-container price in `currency`; null when no rate covers this route. */ + unitAmount: number | null; + currency: string; + /** The USD route rate the quote came from, before conversion. */ + sourceRateUsd: number | null; + /** Why there is no price, for the UI to show instead of a number. */ + unavailableReason: string | null; +} + +export interface EmptyReturnEligibility { + eligible: boolean; + /** Why the customer cannot request one, when `eligible` is false. */ + reason: string | null; + /** Containers on the booking that are not already spoken for. */ + availableContainerNumbers: string[]; + maxContainers: number; + quote: EmptyReturnQuote; +} + +@Injectable() +export class EmptyReturnRequestsService { + constructor( + private readonly requests: EmptyReturnRequestsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly billing: BillingService, + private readonly notifications: NotificationInboxService, + private readonly ratesService: RatesService, + private readonly exchange: ExchangeService, + private readonly dataSource: DataSource, + ) {} + + // ── reads ──────────────────────────────────────────────────────────────── + + async findAll(filter: { + status?: EmptyReturnRequestStatus; + bookingId?: string; + }): Promise< + Array + > { + return this.dataSource.query( + `SELECT r.*, + b.reference AS "bookingReference", + c.name AS "companyName" + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND ($1::text IS NULL OR r.status = $1) + AND ($2::uuid IS NULL OR r.booking_id = $2) + ORDER BY r.submitted_at DESC`, + [filter.status ?? null, filter.bookingId ?? null], + ); + } + + /** One request. A portal caller must own the booking; staff pass `null`. */ + async findById(id: string, userId: string | null = null): Promise { + const request = await this.requests.findById(id); + if (!request) throw new NotFoundException(`Empty return request ${id} not found`); + if (userId) { + const booking = await this.bookingsService.findById(request.bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + } + return request; + } + + /** A booking's own requests — the portal card's history. */ + findForBooking(bookingId: string): Promise { + return this.requests.findAll({ + where: { bookingId }, + order: { submittedAt: 'DESC' }, + }); + } + + /** + * Can this booking ask for an empty return, how many containers are left to + * ask for, and what one would cost. Drives the portal card: the customer + * sees the price before committing, and staff see the same number prefilled + * at approval. + */ + async eligibility(bookingId: string, userId: string | null): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const quote = await this.quote(booking); + const spoken = await this.spokenForContainers(bookingId); + const all = await this.bookingContainerNumbers(bookingId); + const available = all.filter((number) => !spoken.has(number)); + + const reason = this.ineligibilityReason(booking, all.length, available.length); + return { + eligible: reason === null, + reason, + availableContainerNumbers: available, + maxContainers: available.length, + quote, + }; + } + + private ineligibilityReason( + booking: Booking, + bookingContainerCount: number, + availableCount: number, + ): string | null { + if (booking.freightType !== 'CONTAINER') { + return 'Empty container return applies to container freight only.'; + } + if (booking.equipmentReturn === 'WITH_RETURN' || booking.equipmentReturn === 'RETURN') { + return 'This booking already ships with empty container return included.'; + } + if (!REQUESTABLE_BOOKING_STATUSES.includes(booking.status)) { + return `An empty return can be requested once the booking is in transit (current status: ${booking.status}).`; + } + // The customer picks from this booking's own containers, so a booking that + // never captured its container numbers has nothing to pick. + if (bookingContainerCount === 0) { + return 'No container numbers are recorded on this booking — contact EDR to arrange the return.'; + } + if (availableCount === 0) { + return 'Every container on this booking is already on an empty return request.'; + } + return null; + } + + // ── pricing ────────────────────────────────────────────────────────────── + + /** + * Per-container price for returning an empty on this booking, taken from the + * same live WITH_RETURN rate the rule engine bills when the service is + * bought up front (route + trade direction + container type, priced in USD). + * Billed in ETB, converted at the current rate, because this is collected + * locally rather than on the freight invoice. + * + * ponytail: prices off the booking's FIRST container line. A booking mixing + * 20ft and 40ft therefore quotes one size's rate for every box — split the + * quote per container if mixed-size bookings start returning empties. + */ + async quote(booking: Booking): Promise { + const currency = 'ETB'; + if (booking.freightType !== 'CONTAINER') { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: 'Not container freight.', + }; + } + + const [line]: Array<{ containerTypeId: string | null }> = await this.dataSource.query( + `SELECT container_type_id AS "containerTypeId" + FROM freight.booking_container + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC + LIMIT 1`, + [booking.id], + ); + + const rates = await this.ratesService.findLiveRatesDetailed(); + const onLeg = rates.filter( + (rate) => + rate.trigger === 'WITH_RETURN' && + rate.currency === 'USD' && + rate.tradeDirection === booking.tradeDirection && + rate.originYardId === booking.originYardId && + rate.destinationYardId === booking.destinationYardId, + ); + const rate = + onLeg.find((r) => r.containerTypeId === (line?.containerTypeId ?? null)) ?? + onLeg.find((r) => !r.containerTypeId); + + if (!rate) { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: + 'No empty-return rate covers this route and container type — enter the amount manually.', + }; + } + + const usdToEtb = await this.exchange.getRate('USD', 'ETB'); + const rateUsd = Number(rate.rateValue); + return { + unitAmount: Math.round(rateUsd * usdToEtb * 100) / 100, + currency, + sourceRateUsd: rateUsd, + unavailableReason: null, + }; + } + + // ── customer actions ───────────────────────────────────────────────────── + + async create( + dto: CreateEmptyReturnRequestDto, + userId: string | null, + ): Promise { + const booking = await this.bookingsService.findById(dto.bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const numbers = dto.containerNumbers.map((n) => n.trim().toUpperCase()).filter(Boolean); + if (numbers.length === 0) { + throw new BadRequestException('Select at least one container.'); + } + if (new Set(numbers).size !== numbers.length) { + throw new BadRequestException('The same container is selected twice.'); + } + + // Only this booking's own containers can be returned against it. The + // portal offers a pick list, so anything else is a stale page or a + // hand-made request. + const onBooking = new Set(await this.bookingContainerNumbers(booking.id)); + const foreign = numbers.filter((number) => !onBooking.has(number)); + if (foreign.length > 0) { + throw new BadRequestException( + `Not on booking ${booking.reference ?? booking.id}: ${foreign.join(', ')}`, + ); + } + + const reason = this.ineligibilityReason(booking, onBooking.size, numbers.length); + if (reason) throw new BadRequestException(reason); + + await this.assertContainersFree(numbers); + + const saved = await this.requests.create({ + bookingId: booking.id, + companyId: booking.companyId ?? null, + status: 'SUBMITTED', + containerNumbers: numbers, + containerCount: numbers.length, + submittedByUserId: userId, + submittedAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return requested', + body: `${booking.reference ?? booking.id}: a customer asked to return ${numbers.length} empty container${ + numbers.length === 1 ? '' : 's' + }.`, + link: '/dashboard/empty-return-requests', + data: { bookingId: booking.id, requestId: saved.id }, + priority: NotificationPriority.HIGH, + }); + + return saved; + } + + /** Date + truck, once the invoice is settled. This is what the warehouse then expects. */ + async schedule( + id: string, + userId: string | null, + dto: ScheduleEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id, userId); + if (request.status !== 'PAID' && request.status !== 'SCHEDULED') { + throw new BadRequestException( + request.status === 'APPROVED' + ? 'Pay the empty return invoice before booking a date.' + : `This request cannot be scheduled (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'SCHEDULED', + requestedReturnDate: dto.returnDate, + truckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + truckDriverName: dto.truckDriverName.trim(), + truckType: dto.truckType?.trim() ?? null, + scheduledAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty return scheduled', + body: `${request.containerCount} empty container${request.containerCount === 1 ? '' : 's'} arriving ${ + dto.returnDate + } on truck ${dto.truckPlateNumber}.`, + link: '/dashboard/container-returns', + data: { bookingId: request.bookingId, requestId: id }, + }); + + return this.findById(id); + } + + // ── staff actions ──────────────────────────────────────────────────────── + + /** + * Approve and bill. The reviewer's `unitAmount` wins; otherwise the route + * rate stands. The invoice is issued here, so the customer can pay straight + * away — payment lands back on `onInvoicePaid`. + */ + async approve( + id: string, + staffId: string | null, + dto: ApproveEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be approved (current status: ${request.status}).`, + ); + } + + const booking = await this.bookingsService.findById(request.bookingId); + // `chk_invoices_single_payer` requires exactly one payer, and this invoice + // is always billed to the customer — so a booking with no company cannot + // be invoiced at all. Say so here rather than at the constraint. + if (!booking.companyId) { + throw new BadRequestException( + `Booking ${booking.reference ?? booking.id} has no company to bill — the empty return cannot be invoiced.`, + ); + } + + const quote = await this.quote(booking); + const unitAmount = dto.unitAmount ?? quote.unitAmount; + if (!unitAmount || unitAmount <= 0) { + throw new BadRequestException( + quote.unavailableReason ?? 'No price for this return — enter the per-container amount.', + ); + } + + const currency = dto.currency ?? quote.currency; + const totalAmount = Math.round(unitAmount * request.containerCount * 100) / 100; + + const invoice = await this.billing.generateInvoice({ + source: INVOICE_SOURCE as Freight.InvoiceSource, + sourceId: request.id, + type: 'EMPTY_RETURN', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId || '', + currency, + lines: [ + { + chargeType: 'CONTAINER_WITH_RETURN', + description: `Empty container return — ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + } on booking ${booking.reference ?? booking.id}`, + amount: totalAmount, + }, + ], + totalAmount, + }); + + await this.requests.update(id, { + status: 'APPROVED', + quotedUnitAmount: unitAmount, + quotedTotalAmount: totalAmount, + currency, + invoiceId: invoice.id, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'Empty container return approved — payment due', + body: `Your empty return request for booking ${booking.reference ?? booking.id} was approved: ${totalAmount.toLocaleString()} ${currency} for ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + }. Pay the invoice, then choose your return date and truck.`, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, requestId: id, invoiceId: invoice.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + async reject( + id: string, + staffId: string | null, + dto: RejectEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be rejected (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'REJECTED', + reviewedByStaffId: staffId, + reviewedAt: new Date(), + rejectionReason: dto.reason, + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (booking?.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return rejected', + body: `Your empty return request for booking ${booking.reference ?? request.bookingId} was rejected: ${dto.reason}`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + // ── warehouse handoff ──────────────────────────────────────────────────── + + /** + * Scheduled requests the warehouse is waiting on — the planned side of the + * Container Returns screen. Containers already recorded as returned are + * carried per request so staff confirm only what is still outstanding. + */ + async plannedReturns(): Promise< + Array<{ + requestId: string; + bookingId: string; + bookingReference: string | null; + companyName: string | null; + companyId: string | null; + requestedReturnDate: string | null; + truckPlateNumber: string | null; + truckDriverName: string | null; + truckType: string | null; + containers: Array<{ containerNumber: string; returnId: string | null }>; + }> + > { + return this.dataSource.query( + `SELECT r.id AS "requestId", + r.booking_id AS "bookingId", + b.reference AS "bookingReference", + c.name AS "companyName", + r.company_id AS "companyId", + r.requested_return_date AS "requestedReturnDate", + r.truck_plate_number AS "truckPlateNumber", + r.truck_driver_name AS "truckDriverName", + r.truck_type AS "truckType", + ( + SELECT json_agg(json_build_object( + 'containerNumber', n, + 'returnId', ( + SELECT er.id FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = r.booking_id + AND upper(er.container_number) = upper(n) + ORDER BY er.created_at DESC LIMIT 1 + ) + ) ORDER BY ord) + FROM unnest(r.container_numbers) WITH ORDINALITY AS t(n, ord) + ) AS containers + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND r.status = 'SCHEDULED' + ORDER BY r.requested_return_date ASC NULLS LAST, r.scheduled_at ASC`, + ); + } + + /** + * Close a scheduled request once every container it covers has been recorded + * as returned. Called after the warehouse records the returns; a request + * with anything still outstanding stays SCHEDULED. + */ + async settleScheduledForBooking(bookingId: string): Promise { + const open = await this.requests.findAll({ + where: { bookingId, status: 'SCHEDULED' }, + }); + + for (const request of open) { + const [{ outstanding }]: Array<{ outstanding: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS outstanding + FROM unnest($2::text[]) AS n + WHERE NOT EXISTS ( + SELECT 1 FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = $1 + AND upper(er.container_number) = upper(n) + )`, + [bookingId, request.containerNumbers], + ); + if (Number(outstanding) > 0) continue; + + await this.requests.update(request.id, { + status: 'COMPLETED', + completedAt: new Date(), + } as Partial); + } + } + + // ── payment ────────────────────────────────────────────────────────────── + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent(`${INVOICE_SOURCE}.invoice.paid`) + async onInvoicePaid(payload: { sourceId: string }): Promise { + const request = await this.requests.findById(payload.sourceId); + if (!request || request.status !== 'APPROVED') return; + + await this.requests.update(request.id, { + status: 'PAID', + paidAt: new Date(), + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (!booking?.companyId) return; + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Empty return paid — choose your return date', + body: `Payment received for the empty return on booking ${booking.reference ?? request.bookingId}. Tell us the date and the truck bringing the containers back.`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** Container numbers captured on the booking, upper-cased. */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(bcu.container_number) AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.container_number IS NOT NULL + ORDER BY 1`, + [bookingId], + ); + return rows.map((row) => row.containerNumber); + } + + /** Numbers already claimed by a live request on this booking. */ + private async spokenForContainers(bookingId: string): Promise> { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.booking_id = $1 + AND r.status = ANY($2)`, + [bookingId, OPEN_STATUSES], + ); + return new Set(rows.map((row) => row.containerNumber)); + } + + /** A container may only sit on one live request at a time, on any booking. */ + private async assertContainersFree(numbers: string[]): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.status = ANY($1) + AND upper(n) = ANY($2)`, + [OPEN_STATUSES, numbers], + ); + if (rows.length > 0) { + throw new BadRequestException( + `Already on an empty return request: ${rows.map((r) => r.containerNumber).join(', ')}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts new file mode 100644 index 000000000..6bb8257be --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts @@ -0,0 +1,127 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; + +export const EMPTY_RETURN_REQUEST_STATUSES = [ + /** Customer named the containers; waiting on operations. */ + 'SUBMITTED', + /** Operations approved and priced it; the invoice is out, waiting on payment. */ + 'APPROVED', + 'REJECTED', + /** Invoice settled; waiting on the customer to book a date and a truck. */ + 'PAID', + /** Date and truck given — the warehouse now expects these empties. */ + 'SCHEDULED', + /** The empties arrived and were recorded as returns. */ + 'COMPLETED', + 'CANCELLED', +] as const; + +export type EmptyReturnRequestStatus = (typeof EMPTY_RETURN_REQUEST_STATUSES)[number]; + +/** + * A customer's request to return empties on a booking that did NOT buy the + * return service up front (`equipment_return` is not WITH_RETURN). Container + * freight only — a bulk booking has no equipment to give back. + * + * The request carries the commercial half of the flow: which containers, what + * operations priced it at, the invoice, and the date/truck the customer + * booked. The physical return is still recorded in `empty_container_returns` + * when the truck arrives, which is what closes this row out as COMPLETED. + */ +@Entity({ schema: 'freight', name: 'empty_return_requests' }) +@Index(['bookingId']) +@Index(['status']) +export class EmptyReturnRequest extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + /** Denormalised at submit so the queue and the invoice agree on the payer. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 30, default: 'SUBMITTED' }) + status!: EmptyReturnRequestStatus; + + /** The container numbers the customer is sending back, as typed. */ + @Column({ name: 'container_numbers', type: 'text', array: true, default: () => "'{}'" }) + containerNumbers!: string[]; + + @Column({ name: 'container_count', type: 'smallint', default: 0 }) + containerCount!: number; + + /** Per-container price at approval — the route's WITH_RETURN rate, or the reviewer's override. */ + @Column({ + name: 'quoted_unit_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedUnitAmount?: number | null; + + @Column({ + name: 'quoted_total_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedTotalAmount?: number | null; + + @Column({ name: 'currency', type: 'varchar', length: 8, nullable: true }) + currency?: string | null; + + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + /** Customer's chosen day for handing the empties over. */ + @Column({ name: 'requested_return_date', type: 'date', nullable: true }) + requestedReturnDate?: string | null; + + @Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true }) + truckPlateNumber?: string | null; + + @Column({ name: 'truck_driver_name', type: 'varchar', length: 120, nullable: true }) + truckDriverName?: string | null; + + @Column({ name: 'truck_type', type: 'varchar', length: 60, nullable: true }) + truckType?: string | null; + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date | null; + + @Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true }) + submittedByUserId?: string | null; + + @Column({ name: 'submitted_at', type: 'timestamptz', default: () => 'now()' }) + submittedAt!: Date; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason?: string | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts index a840f54a0..d36bbecd6 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -122,6 +122,16 @@ export const customersDataset: ExportDataset = { { value: 'ethiopian', label: 'Ethiopian' }, { value: 'foreign', label: 'Foreign' }, ] }, + // The operational role, NOT `type` above — the list's Role pill. One + // `customer` company routinely holds several profiles, so this asks "who + // does X?" rather than "what kind of company is this?". + { key: 'profileType', label: 'Role', type: 'select', options: [ + { value: 'importer', label: 'Importer' }, + { value: 'exporter', label: 'Exporter' }, + { value: 'freight_forwarder', label: 'Freight forwarder' }, + { value: 'dj_freight_forwarder', label: 'DJ freight forwarder' }, + { value: 'transporter', label: 'Transporter' }, + ] }, // The list's Status filter folds the review queues in, and sends these two // alongside `status`. They are predicates, not columns — see // `company-scope.sql.ts`, shared with the list so both agree exactly. @@ -147,6 +157,18 @@ export const customersDataset: ExportDataset = { if (params.kind) qb.andWhere('c.kind = :kind', { kind: params.kind }); if (params.status) qb.andWhere('c.status = :status', { status: params.status }); if (params.nationality) qb.andWhere('c.nationality = :nationality', { nationality: params.nationality }); + if (params.profileType) { + // EXISTS, matching the list repository exactly — a join here would + // multiply a company holding two profiles into two rows and put the file + // out of step with the count endpoint. + qb.andWhere( + `EXISTS (SELECT 1 FROM freight.company_profiles cp_type + WHERE cp_type.company_id = c.id + AND cp_type.deleted_at IS NULL + AND cp_type.type = :profileType)`, + { profileType: params.profileType }, + ); + } if (params.onboardingCompleted) { const draft = companyDraftSql('c'); qb.andWhere(params.onboardingCompleted === 'true' ? `NOT ${draft}` : draft); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts index 1b473b12a..58724298c 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.spec.ts @@ -2,6 +2,7 @@ import { EXPORT_MIME, formatRowCap, pickByKey, + pickDatasetFields, resolveExportFormat, resolveRowLimit, } from './export-request.util'; @@ -85,3 +86,48 @@ describe('pickByKey', () => { expect(pickByKey(columns, 'ghost,also-ghost')).toEqual(columns); }); }); + +describe('pickDatasetFields', () => { + const fields = [ + { key: 'name', label: 'Company', default: true }, + { key: 'tin', label: 'TIN', default: true }, + { key: 'website', label: 'Website' }, + { key: 'kebele', label: 'Kebele' }, + ]; + const defaults = [fields[0], fields[1]]; + + it.each([undefined, '', ' ', ','])('%p means the default fields', (raw) => { + expect(pickDatasetFields(fields, raw)).toEqual(defaults); + }); + + it('a subset is honoured, in the dataset\'s own order', () => { + expect(pickDatasetFields(fields, 'kebele,name')).toEqual([fields[0], fields[3]]); + }); + + it('asking for EVERY field exports every field', () => { + // The dialog's "All columns" chip sends exactly this. Falling back to the + // defaults here was the bug: 37 ticked customer columns exported as 9. + expect(pickDatasetFields(fields, 'name,tin,website,kebele')).toEqual(fields); + }); + + it('a non-default field alone is not widened back to the defaults', () => { + expect(pickDatasetFields(fields, 'website')).toEqual([fields[2]]); + }); + + it('unknown keys are dropped, the recognised ones still stand', () => { + expect(pickDatasetFields(fields, 'ghost,website')).toEqual([fields[2]]); + }); + + it('all-unknown keys fall back to the defaults, not to everything', () => { + expect(pickDatasetFields(fields, 'ghost,also-ghost')).toEqual(defaults); + }); + + it('surrounding whitespace in a hand-built fields list is tolerated', () => { + expect(pickDatasetFields(fields, ' name , website ')).toEqual([fields[0], fields[2]]); + }); + + it('a dataset with no default flags falls back to every field', () => { + const flat = [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }]; + expect(pickDatasetFields(flat, undefined)).toEqual(flat); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exports/export-request.util.ts b/apps/edr-freight-api/src/modules/exports/export-request.util.ts index 39f1f2e8c..5b57c82f0 100644 --- a/apps/edr-freight-api/src/modules/exports/export-request.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-request.util.ts @@ -56,3 +56,31 @@ export function pickByKey(all: T[], raw: string | und const filtered = requested?.length ? all.filter((c) => requested.includes(c.key)) : all; return filtered.length ? filtered : all; } + +/** + * A dataset's requested field subset, whitelisted against what the caller may + * have. Unlike `pickByKey`, "nothing recognised" falls back to the DEFAULT + * fields rather than to every field — a bookings export declares ~70 columns + * and dumping all of them on an unparameterised call is nobody's intent. + * + * Selecting every field is a legitimate request — the dialog's "All columns" + * chip sends exactly that — so the fallback keys off whether any requested key + * MATCHED, never off how many fields came back. Comparing the picked count to + * `all.length` (as this did originally) made "All columns" silently export the + * default columns instead. + */ +export function pickDatasetFields( + all: T[], + raw: string | undefined, +): T[] { + const requested = new Set( + raw + ?.split(',') + .map((k) => k.trim()) + .filter(Boolean) ?? [], + ); + const picked = requested.size ? all.filter((f) => requested.has(f.key)) : []; + if (picked.length) return picked; + const defaults = all.filter((f) => f.default); + return defaults.length ? defaults : all; +} diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index 46c6ebad2..808e375bc 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -13,7 +13,7 @@ import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util import { EXPORT_MIME, formatRowCap, - pickByKey, + pickDatasetFields, resolveExportFormat, resolveRowLimit, } from './export-request.util'; @@ -105,7 +105,7 @@ export class ExportsController { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const fields = ExportsController.pickFields( + const fields = pickDatasetFields( await resolveDatasetFields(dataset, this.dataSource), query.fields, ); @@ -135,22 +135,6 @@ export class ExportsController { res.send(buffer); } - /** - * Requested fields, whitelisted against the dataset. No `fields=` means the - * DEFAULT set, not everything — a booking export has ~70 fields and dumping - * all of them on an unparameterised call is nobody's intent. - */ - private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] { - if (raw?.trim()) { - const picked = pickByKey(all, raw); - // pickByKey falls back to everything when nothing matched; for a dataset - // the safer read of "all keys unknown" is still the default set. - if (picked.length !== all.length) return picked; - } - const defaults = all.filter((f) => f.default); - return defaults.length ? defaults : all; - } - private resolve(key: string, user: TCurrentUser): ExportDataset { const dataset = getDataset(key); if (!dataset) throw new NotFoundException(`Unknown export dataset: ${key}`); diff --git a/apps/edr-freight-api/src/modules/health/health.controller.spec.ts b/apps/edr-freight-api/src/modules/health/health.controller.spec.ts new file mode 100644 index 000000000..26c873567 --- /dev/null +++ b/apps/edr-freight-api/src/modules/health/health.controller.spec.ts @@ -0,0 +1,100 @@ +import 'reflect-metadata'; + +import type { Response } from 'express'; +import type { DataSource } from 'typeorm'; + +import type { MatrixClient } from '../chat/matrix.client'; +import type { EmailClientService } from '../notifications/email-client.service'; +import type { SmsClientService } from '../notifications/sms-client.service'; +import { HealthController } from './health.controller'; + +type ReadinessBody = { + status: string; + checks: { + chat: { status: string; enabled: boolean; actingAs?: string; error?: string }; + }; +}; + +/** Captures what the controller wrote, in place of an express Response. */ +function recorder() { + const sent: { code?: number; body?: ReadinessBody } = {}; + const res = { + status(code: number) { + sent.code = code; + return this; + }, + json(body: ReadinessBody) { + sent.body = body; + return this; + }, + }; + return { sent, res: res as unknown as Response }; +} + +function controllerWith(matrix: Partial) { + const dataSource = { query: jest.fn(async () => [{ '?column?': 1 }]) }; + return new HealthController( + dataSource as unknown as DataSource, + { brokerConnected: true } as unknown as SmsClientService, + { brokerConnected: true } as unknown as EmailClientService, + matrix as MatrixClient, + ); +} + +describe('HealthController readiness — chat check', () => { + it('reports degraded, not 503, when MATRIX_ADMIN_TOKEN is not a server admin', async () => { + // The dev outage. Chat is broken, but chat is not worth pulling the pod + // out of the load balancer for — bookings and billing still work. + const controller = controllerWith({ + enabled: true, + adminCheck: jest.fn(async () => ({ + ok: false, + actingAs: '@super-admin.f15347:matrixdev.edrsc.com', + error: 'Matrix GET /_synapse/admin/v2/users?limit=1 -> 403: not a server admin', + })), + }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(sent.code).toBe(200); + expect(sent.body?.status).toBe('degraded'); + expect(sent.body?.checks.chat.status).toBe('error'); + // The account name is the actionable half — it says *which* token is wired up. + expect(sent.body?.checks.chat.actingAs).toBe( + '@super-admin.f15347:matrixdev.edrsc.com', + ); + }); + + it('reports ok when the token really is a server admin', async () => { + const controller = controllerWith({ + enabled: true, + adminCheck: jest.fn(async () => ({ + ok: true, + actingAs: '@edrbot:matrixdev.edrsc.com', + })), + }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(sent.body?.status).toBe('ok'); + expect(sent.body?.checks.chat).toMatchObject({ + status: 'ok', + enabled: true, + actingAs: '@edrbot:matrixdev.edrsc.com', + }); + }); + + it('does not call Synapse, or degrade, when chat is switched off', async () => { + const adminCheck = jest.fn(); + const controller = controllerWith({ enabled: false, adminCheck }); + + const { sent, res } = recorder(); + await controller.readiness(res); + + expect(adminCheck).not.toHaveBeenCalled(); + expect(sent.body?.status).toBe('ok'); + expect(sent.body?.checks.chat).toEqual({ status: 'unknown', enabled: false }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/health/health.controller.ts b/apps/edr-freight-api/src/modules/health/health.controller.ts index 6b559f2e3..94b8eb698 100644 --- a/apps/edr-freight-api/src/modules/health/health.controller.ts +++ b/apps/edr-freight-api/src/modules/health/health.controller.ts @@ -7,6 +7,7 @@ import { Public } from "@edr/api-common"; import { Response } from "express"; import { DataSource } from "typeorm"; +import { MatrixClient } from "../chat/matrix.client"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; @@ -32,6 +33,7 @@ export class HealthController { private readonly dataSource: DataSource, private readonly smsClient: SmsClientService, private readonly emailClient: EmailClientService, + private readonly matrix: MatrixClient, ) {} @Get() @@ -45,7 +47,7 @@ export class HealthController { @Public() @ApiOperation({ summary: - "Readiness probe — database plus SMS/email broker connectivity. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true.", + "Readiness probe — database, SMS/email broker connectivity, and the Matrix admin token. Broker failures report as degraded unless READINESS_REQUIRES_BROKER=true; chat failures always report as degraded.", }) async readiness(@Res() res: Response) { const startedAt = Date.now(); @@ -76,23 +78,55 @@ export class HealthController { enabled: process.env.RABBITMQ_ENABLED !== "false", }; + const chat = await this.chatCheck(); + const brokerDown = broker.sms.status === "error" || broker.email.status === "error"; const failed = database.status === "error" || (READINESS_REQUIRES_BROKER && brokerDown); - const status = failed ? "error" : brokerDown ? "degraded" : "ok"; + const status = failed + ? "error" + : brokerDown || chat.status === "error" + ? "degraded" + : "ok"; return res .status(failed ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK) .json({ status, timestamp: new Date().toISOString(), - checks: { database, broker }, + checks: { database, broker, chat }, }); } + /** + * Chat provisioning runs entirely on MATRIX_ADMIN_TOKEN, and a token that is + * valid but not *server admin* fails only the `/_synapse/admin` half: rooms + * are never created, joins never happen, and the sole symptom is an empty + * Element for every employee. Nothing else in the probe would catch that. + * + * Degraded, never a 503 — chat is not worth pulling the pod out of the load + * balancer for, by the same reasoning as the broker check above. `unknown` + * when MATRIX_ENABLED is off: a feature that is switched off is not a fault. + */ + private async chatCheck(): Promise<{ + status: CheckStatus; + enabled: boolean; + actingAs?: string; + error?: string; + }> { + if (!this.matrix.enabled) return { status: "unknown", enabled: false }; + const check = await this.matrix.adminCheck(); + return { + status: check.ok ? "ok" : "error", + enabled: true, + actingAs: check.actingAs, + error: check.error, + }; + } + @Get("info") @Public() @ApiOperation({ summary: "App info — version, environment, uptime" }) diff --git a/apps/edr-freight-api/src/modules/health/health.module.ts b/apps/edr-freight-api/src/modules/health/health.module.ts index 572e5eb86..1aa74f56f 100644 --- a/apps/edr-freight-api/src/modules/health/health.module.ts +++ b/apps/edr-freight-api/src/modules/health/health.module.ts @@ -2,13 +2,15 @@ import { Module } from "@nestjs/common"; +import { ChatModule } from "../chat/chat.module"; import { HealthController } from "./health.controller"; import { NotificationsModule } from "../notifications/notifications.module"; @Module({ // NotificationsModule exports the SMS/email clients; the readiness probe reads // their broker connection state rather than opening a second connection. - imports: [NotificationsModule], + // ChatModule exports MatrixClient for the MATRIX_ADMIN_TOKEN check. + imports: [NotificationsModule, ChatModule], controllers: [HealthController], }) export class HealthModule {} diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts new file mode 100644 index 000000000..848732f47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.spec.ts @@ -0,0 +1,98 @@ +import { + assembleEmptyReturnBookings, + type EmptyReturnBookingUnitRow, +} from './empty-return-bookings.util'; + +const booking = { + bookingId: 'b1', + bookingReference: 'BK-2026-000263', + bookingStatus: 'IN_TRANSIT', + equipmentReturn: 'WITH_RETURN', + customerId: 'c1', + companyName: 'Afri Software Solutions', +}; + +const unit = ( + overrides: Partial & { unitId: string; containerNumber: string }, +): EmptyReturnBookingUnitRow => ({ + ...booking, + containerSize: '40ft', + containerType: '40FT', + returnId: null, + returnStatus: null, + ...overrides, +}); + +describe('assembleEmptyReturnBookings', () => { + it('groups a booking’s flagged containers onto one row, all pending', () => { + const rows = assembleEmptyReturnBookings([ + unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }), + unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }), + ]); + + expect(rows).toHaveLength(1); + expect(rows[0].bookingReference).toBe('BK-2026-000263'); + expect(rows[0].companyName).toBe('Afri Software Solutions'); + expect(rows[0].containers.map((c) => c.containerNumber)).toEqual([ + 'MSFH8596324', + 'SDJU8596324', + ]); + expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 0, pendingCount: 2 }); + }); + + it('keeps an already-recorded container visible but out of the pending count', () => { + const rows = assembleEmptyReturnBookings([ + unit({ + unitId: 'u1', + containerNumber: 'MSFH8596324', + returnId: 'r1', + returnStatus: 'ASSIGNED_STORAGE', + }), + unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }), + ]); + + expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 1, pendingCount: 1 }); + expect(rows[0].containers[0].returnStatus).toBe('ASSIGNED_STORAGE'); + }); + + it('drops a booking once every container is recorded', () => { + const rows = assembleEmptyReturnBookings([ + unit({ + unitId: 'u1', + containerNumber: 'MSFH8596324', + returnId: 'r1', + returnStatus: 'RETURNED', + }), + unit({ + unitId: 'u2', + containerNumber: 'SDJU8596324', + returnId: 'r2', + returnStatus: 'COMPLETED', + }), + ]); + + expect(rows).toEqual([]); + }); + + it('keeps each booking on its own row, in query order', () => { + const other = { + ...booking, + bookingId: 'b2', + bookingReference: 'BK-2026-000286', + companyName: 'DE BE KE', + }; + const rows = assembleEmptyReturnBookings([ + unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }), + { ...unit({ unitId: 'u2', containerNumber: 'ASDS1234567' }), ...other }, + unit({ unitId: 'u3', containerNumber: 'SDJU8596324' }), + ]); + + expect(rows.map((r) => r.bookingReference)).toEqual(['BK-2026-000263', 'BK-2026-000286']); + expect(rows[0].containers).toHaveLength(2); + expect(rows[1].containers).toHaveLength(1); + }); + + it('returns nothing when no booking owes an empty', () => { + expect(assembleEmptyReturnBookings([])).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts new file mode 100644 index 000000000..e2f56df9d --- /dev/null +++ b/apps/edr-freight-api/src/modules/import-operations/empty-return-bookings.util.ts @@ -0,0 +1,107 @@ +import type { EmptyContainerReturnStatus } from './entities/empty-container-return.entity'; + +/** + * `WITH_RETURN` is the current value; `RETURN` is what older bookings were + * written with. Both mean the same thing — the booking owes empties back. + */ +export const WITH_RETURN_EQUIPMENT_VALUES = ['WITH_RETURN', 'RETURN']; + +/** Bookings in these statuses never ship, so they never owe an empty back. */ +export const EMPTY_RETURN_CLOSED_BOOKING_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; + +/** + * One flagged return container of a booking, as the query hands it over: the + * booking columns repeat on every row, and `returnId` is set when this exact + * container already has an empty return recorded against the booking. + */ +export interface EmptyReturnBookingUnitRow { + bookingId: string; + bookingReference: string; + bookingStatus: string; + equipmentReturn: string; + customerId: string | null; + companyName: string | null; + unitId: string; + containerNumber: string; + containerSize: string | null; + containerType: string | null; + returnId: string | null; + returnStatus: EmptyContainerReturnStatus | null; +} + +/** One container a booking owes back empty. */ +export interface EmptyReturnBookingContainer { + /** Stable row key — the booking container unit id. */ + key: string; + unitId: string; + containerNumber: string; + containerSize: string | null; + containerType: string | null; + /** Set once the empty return for this container has been recorded. */ + returnId: string | null; + returnStatus: EmptyContainerReturnStatus | null; +} + +/** A booking that ships with empty-container return and still owes empties. */ +export interface EmptyReturnBookingRow { + bookingId: string; + bookingReference: string; + bookingStatus: string; + equipmentReturn: string; + customerId: string | null; + companyName: string | null; + containers: EmptyReturnBookingContainer[]; + expectedCount: number; + recordedCount: number; + pendingCount: number; +} + +/** + * Groups a booking's flagged return containers onto one row per booking. + * + * A container whose empty return is already recorded keeps its row — the + * screen shows what has been done — but stops counting as pending, and a + * booking with nothing left pending drops off the list entirely. + * + * Row order follows the query (newest booking first, containers in booking + * order), so the caller decides the ordering, not this function. + */ +export function assembleEmptyReturnBookings( + units: EmptyReturnBookingUnitRow[], +): EmptyReturnBookingRow[] { + const rows = new Map(); + + for (const unit of units) { + const row = rows.get(unit.bookingId) ?? { + bookingId: unit.bookingId, + bookingReference: unit.bookingReference, + bookingStatus: unit.bookingStatus, + equipmentReturn: unit.equipmentReturn, + customerId: unit.customerId, + companyName: unit.companyName, + containers: [], + expectedCount: 0, + recordedCount: 0, + pendingCount: 0, + }; + row.containers.push({ + key: unit.unitId, + unitId: unit.unitId, + containerNumber: unit.containerNumber, + containerSize: unit.containerSize, + containerType: unit.containerType, + returnId: unit.returnId, + returnStatus: unit.returnStatus, + }); + rows.set(unit.bookingId, row); + } + + return [...rows.values()] + .map((row) => ({ + ...row, + expectedCount: row.containers.length, + recordedCount: row.containers.filter((container) => container.returnId).length, + pendingCount: row.containers.filter((container) => !container.returnId).length, + })) + .filter((row) => row.pendingCount > 0); +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts index 1116e9440..53875d64d 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.controller.ts @@ -119,6 +119,15 @@ export class ImportOperationsController { return this.service.listEmptyReturns(); } + @Get('empty-return-bookings') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'Bookings shipping with empty-container return that still owe empties, with their containers', + }) + listEmptyReturnBookings() { + return this.service.listEmptyReturnBookings(); + } + @Post('empty-container-returns') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Batch 16: create an empty container return record' }) diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index 8cdc83853..d6cb00fd2 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { EmptyReturnRequestsModule } from '../empty-return-requests/empty-return-requests.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { WarehousesModule } from '../warehouses/warehouses.module'; @@ -26,6 +27,9 @@ import { ImportOperationsService } from './import-operations.service'; BookingsModule, NotificationInboxModule, NotificationsModule, + // Recording a return is what closes out the customer's scheduled empty + // return request, once every container on it is back. + EmptyReturnRequestsModule, ], controllers: [ImportOperationsController], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index 9a84a0551..6333af97e 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -8,6 +8,7 @@ import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util' import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { EmptyReturnRequestsService } from '../empty-return-requests/empty-return-requests.service'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { BulkCreateEmptyContainerReturnsDto, @@ -25,6 +26,13 @@ import { type DjiboutiIncidentType, } from './entities/djibouti-incident.entity'; import { assertWagonLoad } from './empty-container-wagon.util'; +import { + assembleEmptyReturnBookings, + EMPTY_RETURN_CLOSED_BOOKING_STATUSES, + WITH_RETURN_EQUIPMENT_VALUES, + type EmptyReturnBookingRow, + type EmptyReturnBookingUnitRow, +} from './empty-return-bookings.util'; import { EmptyContainerReturn, type EmptyContainerReturnListItem, @@ -57,6 +65,7 @@ export class ImportOperationsService { private readonly logoSettings: LogoSettingsService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly emptyReturnRequests: EmptyReturnRequestsService, ) {} listIncidents(bookingId?: string) { @@ -207,6 +216,51 @@ export class ImportOperationsService { return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never }); } + /** + * Bookings that ship WITH empty-container return and still owe empties, each + * with the containers that are to be returned — the ones the booking flagged + * `is_return`, carrying the empty return already recorded against each, if + * any. + */ + async listEmptyReturnBookings(): Promise { + const units: EmptyReturnBookingUnitRow[] = await this.emptyReturns.manager.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + b.status AS "bookingStatus", + b.equipment_return AS "equipmentReturn", + b.company_id AS "customerId", + c.name AS "companyName", + u.id AS "unitId", + u.container_number AS "containerNumber", + COALESCE(bc.container_size, ct.code) AS "containerSize", + ct.label AS "containerType", + r.id AS "returnId", + r.status AS "returnStatus" + FROM freight.booking_container_units u + JOIN freight.booking_container bc ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL + JOIN freight.bookings b ON b.id = bc.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id + LEFT JOIN LATERAL ( + SELECT er.id, er.status + FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = b.id + AND upper(er.container_number) = upper(u.container_number) + ORDER BY er.created_at DESC + LIMIT 1 + ) r ON TRUE + WHERE u.deleted_at IS NULL + AND u.is_return = true + AND b.equipment_return = ANY($1) + AND b.status <> ALL($2) + ORDER BY b.created_at DESC, u.sort_order ASC`, + [WITH_RETURN_EQUIPMENT_VALUES, EMPTY_RETURN_CLOSED_BOOKING_STATUSES], + ); + + return assembleEmptyReturnBookings(units); + } + async createEmptyReturn(dto: CreateEmptyContainerReturnDto) { const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date(); const saved = await this.emptyReturns.save( @@ -236,6 +290,8 @@ export class ImportOperationsService { // Standalone returns (no booking) have no company to notify. if (saved.bookingId) { await this.notifyEquipmentInterchangeReady(saved); + // Closes the customer's scheduled request once its last container is in. + await this.emptyReturnRequests.settleScheduledForBooking(saved.bookingId); } return saved; } diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index 37612d318..8d56bd0d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -378,6 +378,13 @@ export class LastMileRequestsService { contractGeneratedAt: new Date(), } as Partial); + // Only now, with the request APPROVED, is an advance actually owed on the + // leg. The warehouse auto-accept (IMPORT inspection PASSED) may have already + // opened that leg at READY_TO_TRANSIT, so pull it back to PAYMENT_PENDING — + // otherwise this booking would be dispatchable before the customer has + // signed the contract or paid a birr. No-op for a leg this call just created. + await this.lastMileService.holdForAdvance(lastMile.id); + if (booking.companyId) { void this.notifications.notify({ recipients: { companyId: booking.companyId }, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts new file mode 100644 index 000000000..8e0b8ba55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.advance-gate.spec.ts @@ -0,0 +1,214 @@ +import { BadRequestException } from '@nestjs/common'; +import type { DataSource } from 'typeorm'; + +import { ADVANCE_UNPAID_MESSAGE, LastMileService } from './last-mile.service'; +import type { LastMileStatus } from './entities/last-mile.entity'; +import type { UpdateLastMileDto } from './dto/update-last-mile.dto'; + +/** + * The advance gate: a delivery becomes dispatchable (READY_TO_TRANSIT) or moves + * (IN_TRANSIT) only once the customer has paid the advance the Truck & Machinery + * chief approved. + * + * It used to leak both ways. The warehouse auto-accept (IMPORT inspection + * PASSED) opens the leg at READY_TO_TRANSIT and runs independently of the + * review, so whichever side acted second found the other already done: accept + * first and the leg was dispatchable before an advance was ever asked for; + * approve first and create() handed the existing dispatchable leg straight back + * untouched. + */ +function makeService( + opts: { + /** APPROVED requests on the booking carrying a positive advance. */ + advancesDue?: number; + /** PAID LAST_MILE_ADVANCE invoices on the leg. */ + advancesPaid?: number; + status?: LastMileStatus; + } = {}, +) { + const leg = { + id: 'lm-1', + bookingId: 'b-1', + status: opts.status ?? 'READY_TO_TRANSIT', + vehicleId: 'v-1', + booking: { reference: 'BK-001' }, + }; + + const query = jest.fn((sql: string) => { + if (sql.includes('customer_truck_assignments')) return Promise.resolve([]); + // The batched list enrichment, not the gate's own lookup. + if (sql.includes('FROM freight.last_mile lm')) return Promise.resolve([]); + if (sql.includes('freight.last_mile_requests')) { + return Promise.resolve([{ count: opts.advancesDue ?? 0 }]); + } + // Discriminated on the charge type, not the table: attachMileFinancials + // also queries freight.invoices (for the booking-invoice advance line). + if (sql.includes('LAST_MILE_ADVANCE')) { + return Promise.resolve([{ count: opts.advancesPaid ?? 0 }]); + } + if (sql.includes('FROM freight.bookings')) { + return Promise.resolve([ + { tradeDirection: 'IMPORT', firstMile: null, lastMile: 'Bole, Addis Ababa' }, + ]); + } + return Promise.resolve([]); + }); + + const lastMileRepository = { + findAll: jest.fn().mockResolvedValue([]), + findById: jest.fn().mockResolvedValue(leg), + create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })), + update: jest.fn((_id: string, patch: object) => Promise.resolve({ ...leg, ...patch })), + }; + + const service = new LastMileService( + lastMileRepository as never, + {} as never, // bookingsRepository + { + findById: jest.fn().mockResolvedValue({ + id: 'v-1', + plateNumber: 'AA-123', + assignedDriverId: 'd-1', + assignedDriverName: 'Driver', + }), + setAvailability: jest.fn(), + releaseIfUnused: jest.fn(), + } as never, // vehiclesService + {} as never, // driversService + {} as never, // smsClient + { + query, + // DELIVERED frees the trucks this leg was holding. + manager: { + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + } as unknown as DataSource, + { record: jest.fn() } as never, // history + { findBySourceIds: jest.fn().mockResolvedValue([]) } as never, // billing + { findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService + {} as never, // filesService + ); + + return { service, lastMileRepository, leg }; +} + +const createdStatus = (repo: { create: jest.Mock }) => + (repo.create.mock.calls[0]?.[0] as { status?: string } | undefined)?.status; + +describe('LastMileService - advance gate on creation', () => { + it('opens an auto-accepted leg at PAYMENT_PENDING when an advance is owed', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 1 }); + + // The warehouse path asks for no status at all - it used to get + // READY_TO_TRANSIT and hand the customer a dispatchable unpaid delivery. + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('PAYMENT_PENDING'); + }); + + it('still opens at READY_TO_TRANSIT when no approved request owes an advance', async () => { + const { service, lastMileRepository } = makeService({ advancesDue: 0 }); + + await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never); + + expect(createdStatus(lastMileRepository)).toBe('READY_TO_TRANSIT'); + }); +}); + +describe('LastMileService - advance gate on transitions', () => { + it('refuses IN_TRANSIT while the advance is unpaid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toThrow(ADVANCE_UNPAID_MESSAGE); + }); + + it('refuses a leg being made dispatchable while the advance is unpaid', async () => { + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + await expect( + service.update('lm-1', { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows IN_TRANSIT once the advance invoice is paid', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 1 }); + + const updated = await service.update('lm-1', { + status: 'IN_TRANSIT', + } as UpdateLastMileDto); + + expect(updated.status).toBe('IN_TRANSIT'); + }); + + it('requires one paid advance per approved departure', async () => { + // Containers arriving across two departures get a request - and an advance + // - each. One paid advance does not release the second. + const { service } = makeService({ advancesDue: 2, advancesPaid: 1 }); + + await expect( + service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('lets the paid listener through before the invoice row is visible', async () => { + // Billing emits inline, pre-commit, when the transition joins a caller's + // transaction - so the invoice still reads unpaid here. The event is the + // proof of payment; re-reading the row would refuse the transition the + // payment just earned. + const { service } = makeService({ + advancesDue: 1, + advancesPaid: 0, + status: 'PAYMENT_PENDING', + }); + + const updated = await service.update( + 'lm-1', + { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto, + { advanceSettled: true }, + ); + + expect(updated.status).toBe('READY_TO_TRANSIT'); + }); + + it('leaves states that are not transit alone', async () => { + const { service } = makeService({ advancesDue: 1, advancesPaid: 0 }); + + await expect( + service.update('lm-1', { status: 'DELIVERED' } as UpdateLastMileDto), + ).resolves.toBeDefined(); + }); +}); + +describe('LastMileService.holdForAdvance', () => { + it('pulls an already-dispatchable leg back when approval imposes an advance', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'READY_TO_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).toHaveBeenCalledWith( + 'lm-1', + expect.objectContaining({ status: 'PAYMENT_PENDING' }), + ); + }); + + it('never rewrites a leg that is already on the road', async () => { + const { service, lastMileRepository } = makeService({ + advancesDue: 1, + status: 'IN_TRANSIT', + }); + + await service.holdForAdvance('lm-1'); + + expect(lastMileRepository.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 8ed5ae8aa..4f8acd03a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -59,6 +59,13 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [ 'createdAt', ]; +/** The states that mean the delivery is dispatchable or already on the road. */ +const TRANSIT_STATUSES: LastMileStatus[] = ['READY_TO_TRANSIT', 'IN_TRANSIT']; + +export const ADVANCE_UNPAID_MESSAGE = + 'The last-mile advance has not been paid yet — this delivery cannot become ' + + 'dispatchable or move until the advance invoice is settled.'; + @Injectable() export class LastMileService { private readonly logger = new Logger(LastMileService.name); @@ -93,9 +100,47 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await this.attachAdvanceState(records); await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } + /** + * Flag the legs whose advance is still owed, so the UI can disable the actions + * the API would refuse instead of firing them into a 400. Same rule as + * {@link advanceOutstanding}, batched over the whole page. + */ + private async attachAdvanceState(records: LastMile[]): Promise { + const ids = records.map((r) => r.id).filter(Boolean); + if (!ids.length) return; + const rows: Array<{ lastMileId: string; due: number; paid: number }> = + await this.dataSource.query( + `SELECT lm.id AS "lastMileId", + (SELECT COUNT(*) + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = lm.booking_id + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0)::int AS "due", + (SELECT COUNT(*) + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = lm.id::text + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL)::int AS "paid" + FROM freight.last_mile lm + WHERE lm.id = ANY($1::uuid[]) AND lm.deleted_at IS NULL`, + [ids], + ); + const outstanding = new Map( + rows.map((r) => [r.lastMileId, Number(r.due) > Number(r.paid)]), + ); + for (const r of records) { + (r as LastMile & { advanceOutstanding?: boolean }).advanceOutstanding = + outstanding.get(r.id) ?? false; + } + } + /** Resolve a vehicle's driver + human labels, for stamping mile events onto * the driver's timeline and naming the vehicle. Best-effort — never throws. */ private async vehicleInfo( @@ -185,6 +230,67 @@ export class LastMileService { } } + /** + * Whether this booking still owes an advance on its delivery. + * + * An advance is owed for every APPROVED last-mile request carrying a positive + * approved amount — a booking whose containers arrive across several + * departures gets a request, and therefore an advance, per departure. Each is + * settled by a PAID `LAST_MILE_ADVANCE` invoice raised on the leg when the + * customer signs that request's contract, so the leg is clear only once it has + * as many paid advance invoices as the booking has approved requests. + * + * A booking with no approved request owes nothing and is unaffected: legs that + * never went through the confirmation flow keep behaving exactly as before. + * `lastMileId` is null while the leg is still being created — no invoice can + * point at a row that does not exist yet, so nothing can have been settled. + */ + private async advanceOutstanding( + bookingId: string, + lastMileId: string | null, + ): Promise { + const [due] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.last_mile_requests lmr + WHERE lmr.booking_id = $1 + AND lmr.deleted_at IS NULL + AND lmr.status = 'APPROVED' + AND COALESCE(lmr.approved_advance_amount, 0) > 0`, + [bookingId], + ); + const owed = Number(due?.count ?? 0); + if (!owed) return false; + if (!lastMileId) return true; + + const [paid] = await this.dataSource.query( + `SELECT COUNT(*)::int AS "count" + FROM freight.invoices i + WHERE i.source = 'last_mile' + AND i.source_id = $1 + AND i.type = 'LAST_MILE_ADVANCE' + AND i.status = 'PAID' + AND i.deleted_at IS NULL`, + [lastMileId], + ); + return Number(paid?.count ?? 0) < owed; + } + + /** + * Hold a leg at PAYMENT_PENDING because an advance has just been imposed on it. + * + * The warehouse auto-accept (IMPORT inspection PASSED) opens the leg + * independently of the chief's review, and opens it at READY_TO_TRANSIT. When + * that happens first, approval has to pull the leg back — otherwise the advance + * gate never holds on that ordering and the delivery is dispatchable unpaid. + * A leg already IN_TRANSIT or DELIVERED is left alone: that is a record of what + * happened, not a plan that can still be changed. + */ + async holdForAdvance(id: string): Promise { + const record = await this.findById(id); + if (record.status !== 'READY_TO_TRANSIT') return; + await this.update(id, { status: 'PAYMENT_PENDING' } as UpdateLastMileDto); + } + async acceptBooking(bookingReference: string): Promise { const booking = await this.bookingsRepository.findByReference(bookingReference); @@ -425,9 +531,16 @@ export class LastMileService { await this.assertEdrHaulsThisBooking(dto.bookingId); + // A leg that owes an advance is not dispatchable, whatever the caller asked + // for. The warehouse auto-accept path asks for no status at all and used to + // land straight in READY_TO_TRANSIT, which let an unpaid delivery go. + const status: LastMileStatus = (await this.advanceOutstanding(dto.bookingId, null)) + ? 'PAYMENT_PENDING' + : (dto.status ?? 'READY_TO_TRANSIT'); + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, - status: dto.status ?? 'READY_TO_TRANSIT', + status, advancedPayment: dto.advancedPayment ?? 0, remainingPayment: dto.remainingPayment ?? 0, estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')), @@ -461,11 +574,16 @@ export class LastMileService { async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { try { if (payload.type === 'LAST_MILE_ADVANCE') { - // Advance paid → the leg becomes dispatchable, not delivered. - await this.update(payload.sourceId, { - status: 'READY_TO_TRANSIT', - advancedPayment: payload.totalAmount, - } as unknown as UpdateLastMileDto); + // Advance paid → the leg becomes dispatchable, not delivered. This event + // IS the settlement, so it carries its own way past the advance gate. + await this.update( + payload.sourceId, + { + status: 'READY_TO_TRANSIT', + advancedPayment: payload.totalAmount, + } as unknown as UpdateLastMileDto, + { advanceSettled: true }, + ); this.logger.log( `Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`, ); @@ -484,9 +602,32 @@ export class LastMileService { } } - async update(id: string, dto: UpdateLastMileDto): Promise { + /** + * `opts.advanceSettled` is the paid listener's own bypass, and nothing else + * should pass it: the invoice event is itself the proof of payment, and it can + * reach us inline before the invoice row commits (billing emits before commit + * when the transition is enlisted in a caller-supplied manager), so re-reading + * the invoice here would still see it unpaid and refuse the very transition the + * payment just earned. + */ + async update( + id: string, + dto: UpdateLastMileDto, + opts: { advanceSettled?: boolean } = {}, + ): Promise { const existing = await this.findById(id); + // Nothing becomes dispatchable, and nothing moves, until the advance is paid. + if ( + !opts.advanceSettled && + dto.status !== undefined && + dto.status !== existing.status && + TRANSIT_STATUSES.includes(dto.status) && + (await this.advanceOutstanding(existing.bookingId, id)) + ) { + throw new BadRequestException(ADVANCE_UNPAID_MESSAGE); + } + // A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle // assigned in this same request). if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') { diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 467b172ba..1dd8ad9ca 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady( logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); } } + +/** One container line on the load manifest notice. */ +interface LoadManifestLists { + reference: string; + companyId: string | null; + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + loaded: string[]; + leftBehind: string[]; +} + +/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */ +function summarizeNumbers(numbers: string[], max = 5): string { + if (numbers.length === 0) return 'none'; + const shown = numbers.slice(0, max).join(', '); + const rest = numbers.length - max; + return rest > 0 ? `${shown} +${rest} more` : shown; +} + +/** + * Read what actually went on the train and what did not. Left behind = every + * container the customer declared minus the ones sitting on a LOADED/DEPARTED + * wagon, so a booking loaded in parts reports honestly on both halves. + */ +export async function loadManifestLists( + dataSource: DataSource, + bookingId: string, + trainScheduleId: string, +): Promise { + const [booking]: Array<{ reference: string; companyId: string | null }> = + await dataSource.query( + `SELECT reference, company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) return null; + + const [train]: Array<{ + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + }> = await dataSource.query( + `SELECT s.train_number AS "trainNumber", + so.label AS "originStation", + sd.label AS "destinationStation", + s.scheduled_departure_date AS "departureAt" + FROM freight.train_schedules s + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + WHERE s.id = $1 AND s.deleted_at IS NULL`, + [trainScheduleId], + ); + + const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT ci.container_number AS "containerNumber" + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations a + ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL + WHERE a.booking_id = $1 + AND ci.deleted_at IS NULL + AND a.status IN ('LOADED', 'DEPARTED') + ORDER BY 1`, + [bookingId], + ); + const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT u.container_number AS "containerNumber" + FROM freight.booking_container_units u + JOIN freight.booking_container l + ON l.id = u.booking_container_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND u.deleted_at IS NULL + ORDER BY 1`, + [bookingId], + ); + + const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[]; + const loadedSet = new Set(loaded); + const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter( + (n) => !loadedSet.has(n), + ); + + return { + reference: booking.reference, + companyId: booking.companyId, + trainNumber: train?.trainNumber ?? null, + originStation: train?.originStation ?? null, + destinationStation: train?.destinationStation ?? null, + departureAt: train?.departureAt ?? null, + loaded, + leftBehind, + }; +} + +/** + * Tell the customer what boarded the train and what did not, over in-app + SMS + * + email, and raise a warehouse-desk notice for anything left behind so + * somebody owns finding it space. A booking is routinely loaded in parts, and + * before this the customer learnt about it only by reading the sheet. + * + * Best-effort throughout: loading must never roll back because a provider is + * down. + */ +export async function notifyLoadManifest( + dataSource: DataSource, + notifications: NotificationsService, + inbox: NotificationInboxService, + bookingId: string, + trainScheduleId: string, + warehouseNotificationPermission: string, + logger: Logger, +): Promise { + try { + const m = await loadManifestLists(dataSource, bookingId, trainScheduleId); + if (!m) return; + + const route = + m.originStation && m.destinationStation + ? ` ${m.originStation} → ${m.destinationStation}` + : ''; + const departs = m.departureAt + ? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}` + : ''; + const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train'; + + const headline = + `Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` + + `${route}${departs}.`; + const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : ''; + const leftLine = + m.leftBehind.length > 0 + ? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` + + ' These stay with EDR — once a warehouse is assigned you will receive the GRN.' + : ''; + const body = headline + loadedLine + leftLine; + + if (m.companyId) { + await inbox.notify({ + recipients: { companyId: m.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded', + // The in-app copy carries every number; SMS and email get the summary. + body: + headline + + (m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') + + (m.leftBehind.length > 0 + ? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.` + : ''), + link: `/bookings/${bookingId}`, + data: { + bookingId, + reference: m.reference, + trainNumber: m.trainNumber, + loaded: m.loaded, + leftBehind: m.leftBehind, + }, + }); + await sendCompanyChannels(dataSource, notifications, m.companyId, body); + } + + // Nothing left behind is nothing for the warehouse desk to place. + if (m.leftBehind.length > 0) { + await inbox.notify({ + recipients: { permissionKeys: [warehouseNotificationPermission] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`, + body: + `${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` + + `${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`, + link: `/dashboard/booking-requests/${bookingId}`, + data: { bookingId, reference: m.reference, leftBehind: m.leftBehind }, + }); + } + } catch (err) { + logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`); + } +} diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 00f8d107a..086baef66 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -35,9 +35,24 @@ describe("isDomesticPhone", () => { (phone) => expect(isDomesticPhone(phone)).toBe(true), ); - it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])( - "rejects non-domestic or malformed %s", - (phone) => expect(isDomesticPhone(phone)).toBe(false), + // Djibouti is the line's other end: the gateway reaches its 77x mobiles. + it.each(["+25377123456", "25377123456", "77123456"])( + "accepts Djibouti mobile form %s", + (phone) => expect(isDomesticPhone(phone)).toBe(true), + ); + + it.each([ + "+14155550123", + "+447911123456", + "0712345678", + "+2519866", + "12345", + // Djibouti fixed line (2x) — valid number, not a mobile the gateway serves. + "+25321350000", + // Right length, wrong Djibouti prefix. + "+25366123456", + ])("rejects unreachable or malformed %s", (phone) => + expect(isDomesticPhone(phone)).toBe(false), ); }); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index b27520c25..6b5a0e0d9 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -42,20 +42,42 @@ function normalizePhone(rawPhone: string): string { if (digits.startsWith("+")) return digits; const bare = digits.replace(/^0+/, ""); if (/^251\d{9}$/.test(digits)) return `+${digits}`; + if (/^253\d{8}$/.test(digits)) return `+${digits}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; + // Djibouti mobiles are 8 digits starting 77 and have no trunk prefix, so a + // bare "77…" is unambiguous — it cannot be an Ethiopian local number, which + // is always 9 digits after the trunk zero. + if (/^77\d{6}$/.test(bare)) return `+253${bare}`; // Unknown shape (foreign number, already-clean intl without +) — prefix + if // it looks like a full international number, else leave as typed. return digits.length >= 11 ? `+${digits}` : raw; } /** - * Whether a phone is an Ethiopian mobile the SMS gateway can actually reach — - * the carrier integration is domestic-only, so a send to anything else is - * queued and silently lost. Callers use this to fall back to email instead of - * pretending an SMS is on its way. + * Mobile ranges the SMS gateway is contracted to reach, as E.164 patterns. + * + * The gateway itself is opaque from here — `SmsClientService` publishes to + * RabbitMQ and the carrier sits several hops downstream — so this list is a + * policy statement, not a capability probe: a number outside it is treated as + * unreachable and callers fall back to email rather than promising an SMS that + * would be queued and silently dropped. + * + * - Ethiopia: `+2519…` mobiles only. `+2517…` is deliberately absent; it parses + * as a valid ET number but is not a range this gateway delivers to. + * - Djibouti: `+25377…`, the country's only mobile range (2x is fixed-line). + */ +const REACHABLE_MOBILE_PATTERNS = [/^\+2519\d{8}$/, /^\+25377\d{6}$/]; + +/** + * Whether a phone sits in a mobile range the SMS gateway can actually reach. + * + * Named "domestic" for the Ethiopian-only era this predates; it now covers both + * countries the railway runs through. Callers use it to fall back to email + * instead of pretending an SMS is on its way. */ export function isDomesticPhone(rawPhone: string): boolean { - return /^\+2519\d{8}$/.test(normalizePhone(rawPhone)); + const normalized = normalizePhone(rawPhone); + return REACHABLE_MOBILE_PATTERNS.some((p) => p.test(normalized)); } /** @@ -99,7 +121,7 @@ export class OtpService { private readonly otpRepository: OtpRepository, private readonly notifications: NotificationsService, private readonly emailClient: EmailClientService, - ) { } + ) {} // --------------------------------------------------------------------------- // Generate OTP @@ -197,8 +219,10 @@ export class OtpService { for (const outcome of outcomes) { this.logger.log( - `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued - } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" + `otp.dispatch channel=${outcome.channel} target=${label} queued=${ + outcome.queued + } latencyMs=${Date.now() - startedAt}${ + outcome.error ? ` error=${outcome.error}` : "" }`, ); } @@ -222,7 +246,8 @@ export class OtpService { // user who never receives a code — indistinguishable from carrier loss, // and the misleading success response makes it look like our side worked. this.logger.error( - `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset" + `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${ + process.env.RABBITMQ_ENABLED ?? "unset" } — no transport reported hand-off; no code will arrive for this send`, ); } @@ -247,7 +272,8 @@ export class OtpService { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( - `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt + `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${ + Date.now() - startedAt }: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); @@ -330,8 +356,9 @@ export class OtpService { ) { const line = `otp.verify channels=${channelsOf(target).join( "+", - )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : "" - }`; + )} target=${this.targetLabel(target)} mode=${mode} result=${result}${ + detail ? ` ${detail}` : "" + }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3fbecc036..f035f8a64 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -508,12 +508,12 @@ export class RuleEngineService { if (input.tradeDirection === 'IMPORT') { appliedModifiers.push( - ...this.derivedImportOverweight( + ...(await this.derivedImportOverweight( input, containerWeightResults, lineMaxVgmTons, liveRates, - ), + )), ); } @@ -540,23 +540,37 @@ export class RuleEngineService { } /** - * Import overweight — derived, never configured. Each overweight container - * line bills its excess tons at (its own base import freight on the booking's - * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit → - * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. - * Note: derives from the LIVE route rate even for frozen-rate contract - * bookings — the frozen snapshot has no route-scoped container price to - * divide. + * Import overweight — derived, never configured. Excess tons are billed on a + * PER-WAGON basis: (the wagon's base import freight on the booking's route) + * ÷ (2 × the container's weight limit). + * + * The rate is normalised to a wagon before dividing, because a 20ft rate + * quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon — + * while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is + * doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is: + * - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25 + * - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95 + * Halving over 2 × the limit keeps the original meaning: filling one wagon's + * worth of excess costs one extra wagon of freight. + * + * Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE + * route rate even for frozen-rate contract bookings — the frozen snapshot has + * no route-scoped container price to divide. */ - private derivedImportOverweight( + private async derivedImportOverweight( input: BookingEvaluationInput, weightResults: ContainerWeightResult[], lineMaxVgmTons: Array, liveRates: Rate[], - ): AppliedCargoModifier[] { + ): Promise { const modifiers: AppliedCargoModifier[] = []; if (!input.originYardId || !input.destinationYardId) return modifiers; + // How many of each container type ride one wagon: a 40ft fills a wagon, + // two 20ft share one. Keyed by container type so a PER_CONTAINER rate can + // be scaled up to the wagon the overweight formula prices against. + const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults); + for (let i = 0; i < weightResults.length; i++) { const wr = weightResults[i]; const excess = Number(wr?.overweightExcessTons ?? 0); @@ -578,7 +592,16 @@ export class RuleEngineService { // No base rate → the base-freight line hard-blocks this booking anyway. if (!base) continue; - const perTon = Number(base.rateValue) / (2 * maxVgm); + // Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft + // rate covers half a wagon, so it is scaled by the 2 containers that ride + // one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon + // price already — never scale it again. + const perWagonRate = + base.rateUnit === 'PER_CONTAINER' + ? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1) + : Number(base.rateValue); + + const perTon = perWagonRate / (2 * maxVgm); const amount = excess * perTon; if (!(amount > 0)) continue; @@ -595,6 +618,42 @@ export class RuleEngineService { return modifiers; } + /** + * Containers of each type that ride ONE wagon, derived from the type's + * size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the + * caller actually needs are looked up. Unknown or non-positive sizes fall + * back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing + * behaviour, so a missing size can never inflate a bill. + */ + private async containersPerWagonByTypeId( + weightResults: ContainerWeightResult[], + ): Promise> { + const perWagon = new Map(); + const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))]; + if (ids.length === 0) return perWagon; + + let rows: Array<{ id: string; size_ft: string | number | null }> = []; + try { + rows = await this.dataSource.query( + 'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)', + [ids], + ); + } catch { + // Size lookup unavailable — fall back to an unscaled (×1) rate, the + // behaviour before per-wagon normalisation. Never fail pricing over it. + return perWagon; + } + const WAGON_SLOT_FT = 40; + for (const row of rows) { + const sizeFt = Number(row.size_ft ?? 0); + perWagon.set( + row.id, + sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1, + ); + } + return perWagon; + } + /** * Empty-container return — sold per direction + route + container type, like * base freight. Each container line that opted in (returnQuantity, or every diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7c41ee43a..23b3f164e 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -270,7 +270,7 @@ export class SchedulingRescheduleService { // M12: only announce a new departure when the date actually moved — // `newDeparture` is null when the date was unchanged, so retained customers // are not falsely told the train was rescheduled. - await this.notifyRescheduleOutcome(dto, newDeparture); + await this.notifyRescheduleOutcome(scheduleId, dto, newDeparture); if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId); return { plan, schedule: assignResult }; @@ -283,6 +283,7 @@ export class SchedulingRescheduleService { * company so the notifier has a phone/email to reach. */ private async notifyRescheduleOutcome( + scheduleId: string, dto: ExecuteRescheduleDto, newDeparture: Date | null, ): Promise { @@ -294,9 +295,9 @@ export class SchedulingRescheduleService { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; if (isMaintenance) { - this.notifier.maintenanceMoved(booking, newDeparture); + this.notifier.maintenanceMoved(booking, newDeparture, scheduleId, dto.reason); } else { - this.notifier.rescheduled(booking, newDeparture); + this.notifier.rescheduled(booking, newDeparture, scheduleId, dto.reason); } } } @@ -307,7 +308,8 @@ export class SchedulingRescheduleService { for (const bookingId of dto.displacedBookingIds) { const booking = await this.loadBookingForNotify(bookingId); if (!booking) continue; - this.notifier.removedFromTrain(booking); + // Displaced bookings no longer point at the schedule — pass it explicitly. + this.notifier.removedFromTrain(booking, scheduleId); } } } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts new file mode 100644 index 000000000..dfafcee43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.spec.ts @@ -0,0 +1,32 @@ +import { trainRunLabel } from './train-run-label.util'; + +describe('trainRunLabel', () => { + it('names the departure by the schedule train number and voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' })).toBe( + 'train 8001 (voyage V-117)', + ); + }); + + it('drops the voyage bracket when the schedule has no voyage number', () => { + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: null })).toBe('train 8001'); + expect(trainRunLabel({ trainNumber: '8001', voyageNumber: ' ' })).toBe('train 8001'); + }); + + it('still quotes the voyage when the pool train number is not assigned yet', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: 'V-117' })).toBe( + 'train (voyage V-117)', + ); + }); + + it('returns null when neither number is known so callers can fall back', () => { + expect(trainRunLabel({ trainNumber: null, voyageNumber: null })).toBeNull(); + expect(trainRunLabel(null)).toBeNull(); + expect(trainRunLabel(undefined)).toBeNull(); + }); + + it('capitalizes for sentence starts on request', () => { + expect( + trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' }, { capitalize: true }), + ).toBe('Train 8001 (voyage V-117)'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts new file mode 100644 index 000000000..3c13506ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-run-label.util.ts @@ -0,0 +1,30 @@ +import { TrainSchedule } from './entities/train-schedule.entity'; + +export type TrainRunSource = Pick; + +/** + * How a departure is named in every customer-facing SMS / email: + * + * "train 8001 (voyage V-2026-117)" + * + * Both identifiers are the SCHEDULE's own columns — `train_schedules.train_number` + * and `train_schedules.voyage_number`. The built train (`freight.trains`) carries + * a `train_name` that the build form labels "voyage number"; that is a different + * identifier and must never be quoted to customers. Always pass the schedule. + * + * Returns null when the schedule has neither number (older rows, or an unbuilt + * departure whose pool number is assigned at dispatch) so callers can fall back + * to a generic phrase instead of printing "train (voyage)". + */ +export function trainRunLabel( + schedule: TrainRunSource | null | undefined, + opts: { capitalize?: boolean } = {}, +): string | null { + if (!schedule) return null; + const train = schedule.trainNumber?.trim() || null; + const voyage = schedule.voyageNumber?.trim() || null; + if (!train && !voyage) return null; + const head = train ? `train ${train}` : 'train'; + const label = voyage ? `${head} (voyage ${voyage})` : head; + return opts.capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index bf9c0aa5f..c933a5e34 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -607,7 +607,6 @@ export class BookingBatchService implements OnModuleInit { const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || booking.status === "AWAITING_PAYMENT" || - booking.status === "PAID" || booking.paymentStatus === "PAID"; if (!isBatchPaid) return; @@ -786,7 +785,7 @@ export class BookingBatchService implements OnModuleInit { `SELECT id FROM freight.bookings WHERE deleted_at IS NULL AND train_schedule_id IS NULL - AND (payment_status = 'PAID' OR status = 'PAID') + AND payment_status = 'PAID' AND scheduled_date IS NOT NULL AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`, [day], @@ -3476,7 +3475,7 @@ export class BookingBatchService implements OnModuleInit { schedule?.scheduledDepartureDate && eatDay(schedule.scheduledDepartureDate) !== previousDay ) { - this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate); + this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate, schedule); } } @@ -3819,7 +3818,6 @@ export class BookingBatchService implements OnModuleInit { fresh.trainScheduleId === scheduleId && (fresh.status === "SELECTED_FOR_BATCH" || fresh.status === "AWAITING_PAYMENT" || - fresh.status === "PAID" || fresh.paymentStatus === "PAID") ) { this.logger.debug( @@ -3967,6 +3965,11 @@ export class BookingBatchService implements OnModuleInit { schedulingStatus: "SCHEDULED", scheduledAt: new Date(), wagonsRequired, + // Pinned for cancellation pricing: unassign clears wagonsRequired, this + // stays. Written once — a later re-allocation keeps the first stamp. + ...(Number(booking.cancellationWagons ?? 0) > 0 + ? {} + : { cancellationWagons: wagonsRequired }), paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -4530,7 +4533,7 @@ export class BookingBatchService implements OnModuleInit { manager, ); }); - this.notifier.displaced(victim); + this.notifier.displaced(victim, scheduleId); budget.add(this.needFor(victim, wagonDims), victimLeg); // Displacing frees wagons the same way an expiry does — don't leave the // schedule stuck at FULL. @@ -5484,7 +5487,6 @@ export class BookingBatchService implements OnModuleInit { ).filter( (b) => b.paymentStatus === "PAID" || - b.status === "PAID" || !payWindowLapsed(b.paymentDeadline, deadlineCutoff), ); // Export FCFS: a customer's pending operation request HOLDS its wagons from @@ -5596,7 +5598,6 @@ export class BookingBatchService implements OnModuleInit { return reserved.some( (b) => b.paymentStatus !== "PAID" && - b.status !== "PAID" && b.paymentDeadline != null && !payWindowLapsed(b.paymentDeadline, now), ); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts index ea936465e..91136ab97 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -13,6 +13,7 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { { emit: jest.fn() } as never, // events {} as never, // notifications {} as never, // inbox + { record: jest.fn() } as never, // wagonHistory ); const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 07de05853..cc060bf4e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -31,7 +31,10 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; -import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; +import { notifyCarriageAcceptanceReady,notifyLoadManifest } from '../notifications/notify-company.util'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; + /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -61,12 +64,18 @@ export class BookingJourneyService { private readonly events: EventEmitter2, private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + private readonly wagonHistory: WagonHistoryService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} - /** Statuses from which a booking may be loaded (gov bookings don't prepay). */ + /** + * Whether a booking may be loaded. Paid is decided by the booking's + * PAYMENT status only — never by `status === 'PAID'`, which lags or is + * skipped on several flows (batch pay, manual mark-paid, gov expedite). + * Government bookings don't prepay: APPROVED is enough for them. + */ private canLoad(booking: Booking): boolean { - if (booking.status === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; return booking.isGovernment && booking.status === 'APPROVED'; } @@ -117,6 +126,7 @@ export class BookingJourneyService { loadedAt: now, loadedByUserId: userId ?? null, }); + await this.wagonHistory.record(manager, this.cargoEvent(target, schedule, booking, 'LOADED', now, userId ?? null)); if (!booking.loadingStartedAt) { await manager .getRepository(Booking) @@ -188,7 +198,8 @@ export class BookingJourneyService { } if (!this.canLoad(booking)) { throw new BadRequestException( - `Booking must be paid before loading (currently ${booking.status})`, + `Booking must be paid before loading (payment status ${booking.paymentStatus ?? 'PENDING'}, ` + + `booking status ${booking.status})`, ); } await this.assertTrainAtYard(schedule, booking.originYardId, 'origin'); @@ -235,7 +246,12 @@ export class BookingJourneyService { if (booking.tradeDirection === 'DOMESTIC') { await this.autoPlaceOnFreedWagons(manager, schedule, booking); } - await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED', { + userId: userId ?? null, + at: now, + schedule, + booking, + }); // Keep the schedule↔booking link's tracking flag in sync — the dispatch // readiness warnings and workspace badges read loading_status, not loadedAt. await manager @@ -267,6 +283,20 @@ export class BookingJourneyService { }); }); + // What actually boarded, and what did not. A booking is routinely loaded in + // parts; the customer is told both halves, and the warehouse desk is told + // about the leftovers so somebody owns placing them. After the transaction: + // the lists are read back from the allocation statuses it just wrote. + void notifyLoadManifest( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + scheduleId, + FREIGHT_PERMS.warehouseInventory.getNotification, + this.logger, + ); + // Customer tracking: cargo is on the train — loading milestones plus the // direction's "departed" handoff. Doc-trigger path no-ops non-customs // bookings (intercity) and already-completed codes. @@ -328,6 +358,7 @@ export class BookingJourneyService { unloadedAt: now, unloadedByUserId: userId ?? null, }); + await this.wagonHistory.record(null, this.cargoEvent(target, schedule, booking, 'DEPARTED', now, userId ?? null)); const remaining = allocations.filter( (a) => a.id !== target.id && a.status !== 'DEPARTED', @@ -385,7 +416,12 @@ export class BookingJourneyService { arrivedAt: now, arrivedByUserId: userId ?? null, } as never); - await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED'); + await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED', { + userId: userId ?? null, + at: now, + schedule, + booking, + }); await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null); // The facility took the cargo off the train — raise its GRN. Where the // facility also stores cargo (Indode), the event links the storage record @@ -464,6 +500,7 @@ export class BookingJourneyService { id: b.id, reference: b.reference, status: b.status, + paymentStatus: b.paymentStatus ?? null, tradeDirection: b.tradeDirection, isGovernment: b.isGovernment, customer: b.company?.name ?? 'Unknown customer', @@ -901,12 +938,61 @@ export class BookingJourneyService { scheduleId: string, bookingId: string, status: 'LOADED' | 'DEPARTED', + ctx?: { userId: string | null; at: Date; schedule: TrainSchedule; booking: Booking }, ): Promise { const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId); if (!allocations.length) return; await manager .getRepository(WagonBookingAllocation) .update({ id: In(allocations.map((a) => a.id)) }, { status }); + if (!ctx) return; + // Per-wagon cargo history. Allocations already at (or past) the target + // status were logged by the per-wagon load/unload endpoint — skip them so + // the whole-booking completion never double-writes a wagon's row. + const pending = allocations.filter((a) => + status === 'LOADED' + ? a.status !== 'LOADED' && a.status !== 'DEPARTED' + : a.status !== 'DEPARTED', + ); + await this.wagonHistory.record( + manager, + pending + .map((a) => this.cargoEvent(a, ctx.schedule, ctx.booking, status, ctx.at, ctx.userId)) + .filter((e): e is WagonEventInput => e !== null), + ); + } + + /** CARGO_LOADED / CARGO_UNLOADED row for one allocation's physical wagon; null when the slot has no wagon pinned. */ + private cargoEvent( + alloc: WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }, + schedule: TrainSchedule, + booking: Booking, + status: 'LOADED' | 'DEPARTED', + at: Date, + userId: string | null, + ): WagonEventInput | null { + const slot = alloc.trainSetWagon; + if (!slot?.physicalWagonId) return null; + const loaded = status === 'LOADED'; + return { + wagonId: slot.physicalWagonId, + wagonNumber: slot.physicalWagon?.wagonNumber ?? null, + type: loaded ? Freight.WagonEventType.CargoLoaded : Freight.WagonEventType.CargoUnloaded, + occurredAt: at, + actorUserId: userId, + toYardId: loaded + ? (slot.boardYardId ?? schedule.originStationId ?? null) + : (booking.destinationYardId ?? slot.alightYardId ?? schedule.destinationStationId ?? null), + trainScheduleId: schedule.id, + trainId: schedule.trainSet?.trainId ?? null, + bookingId: booking.id, + toValue: booking.reference ?? null, + metadata: { + allocationId: alloc.id, + loadType: alloc.loadType ?? null, + weightTons: Number(alloc.allocatedWeightTons ?? 0), + }, + }; } private async allocationsForBooking( @@ -994,6 +1080,20 @@ export class BookingJourneyService { ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available, }); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: Freight.WagonEventType.ReleasedAtUnload, + occurredAt: now, + actorUserId: userId, + fromYardId: boardYardId ?? null, + toYardId: booking.destinationYardId ?? null, + trainScheduleId: schedule.id, + trainId: wagon.trainId ?? null, + bookingId: booking.id, + toValue: wagon.trainId ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available, + metadata: { slotId: slot.id }, + }); } } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts new file mode 100644 index 000000000..524eeddc7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.spec.ts @@ -0,0 +1,76 @@ +import { BookingNotifierService } from './booking-notifier.service'; + +/** + * Message wording for the schedule-related customer notices: every one must + * quote the SCHEDULE's train + voyage numbers, and reschedules must carry the + * staff-entered reason instead of a hard-coded "for maintenance". + */ +describe('BookingNotifierService messages', () => { + const schedule = { trainNumber: '8001', voyageNumber: 'V-117' }; + const booking = { id: 'b1', reference: 'BK-2026-000928', companyId: 'c1' } as never; + const departure = new Date('2026-09-01T05:00:00.000Z'); + + let sent: string[]; + let inbox: string[]; + let service: BookingNotifierService; + + beforeEach(() => { + sent = []; + inbox = []; + const notifications = { + directSend: jest.fn(async (_m: string, _to: string, msg: string) => { + sent.push(msg); + }), + }; + const inboxSvc = { + notify: jest.fn(async (input: { body: string }) => { + inbox.push(input.body); + }), + }; + const trainSchedules = { + findByIdWithStations: jest.fn(async () => ({ ...schedule, reference: 'S-2026-00012' })), + }; + // Company contact lookup goes through raw SQL; return one phone + email. + const dataSource = { + query: jest.fn(async () => [{ phone: '+251900000000', email: 'ops@example.com' }]), + }; + service = new BookingNotifierService( + notifications as never, + inboxSvc as never, + trainSchedules as never, + dataSource as never, + ); + }); + + const flush = () => new Promise((r) => setImmediate(r)); + + it('maintenance reschedule quotes train, voyage and the staff reason', async () => { + service.maintenanceMoved(booking, departure, schedule, 'Locomotive maintenance.'); + await flush(); + expect(inbox[0]).toBe( + 'Train 8001 (voyage V-117) for booking BK-2026-000928 was rescheduled — reason: Locomotive maintenance. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('maintenance reschedule falls back to "for maintenance" without a reason', async () => { + service.maintenanceMoved(booking, departure, schedule, ' '); + await flush(); + expect(inbox[0]).toContain('was rescheduled for maintenance. New departure date'); + }); + + it('plain reschedule carries the reason and the run label', async () => { + service.rescheduled(booking, departure, schedule, 'Crew change'); + await flush(); + expect(inbox[0]).toBe( + 'Booking BK-2026-000928 on train 8001 (voyage V-117) has been rescheduled — reason: Crew change. ' + + 'New departure date: 01/09/2026.', + ); + }); + + it('resolves the run label from a schedule id when only the id is known', async () => { + service.scheduleCancelled(booking, 'sched-1'); + await flush(); + expect(inbox[0]).toMatch(/^Train 8001 \(voyage V-117\) for booking BK-2026-000928 has been cancelled/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index 1c2d99401..fd42fdd7b 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -14,8 +14,21 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util'; import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { trainRunLabel, type TrainRunSource } from '../train-schedules/train-run-label.util'; import { BATCH_TIMEZONE } from './booking-batch.constants'; +const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1); + +/** + * " — reason: Locomotive maintenance" for the staff-entered reschedule reason, + * or '' when none was given. Trailing punctuation is trimmed so the sentence's + * own full stop follows cleanly. + */ +const reasonClause = (reason?: string | null): string => { + const text = reason?.trim().replace(/[.\s]+$/, ''); + return text ? ` — reason: ${text}` : ''; +}; + @Injectable() export class BookingNotifierService { private readonly logger = new Logger(BookingNotifierService.name); @@ -30,8 +43,9 @@ export class BookingNotifierService { /** * Human-readable description of a train schedule for customer messages: - * reference (or train number) + route + departure date. Never leaks a UUID — - * falls back to a generic phrase when the schedule can't be loaded. + * train number + voyage number (both the SCHEDULE's own — see trainRunLabel), + * then reference, route and departure date. Never leaks a UUID — falls back + * to a generic phrase when the schedule can't be loaded. */ private async scheduleLabel(scheduleId?: string | null): Promise { const fallback = 'your selected train'; @@ -39,15 +53,32 @@ export class BookingNotifierService { try { const s = await this.trainSchedules.findByIdWithStations(scheduleId); if (!s) return fallback; - const ref = s.reference ?? s.trainNumber ?? null; - const route = + // Customers know the departure by its train number (8001) and voyage + // number, not the schedule reference — lead with those and keep S-… as + // the secondary id. + const run = trainRunLabel(s); + const parts = [ + s.reference, s.originStation?.label && s.destinationStation?.label - ? ` (${s.originStation.label} → ${s.destinationStation.label})` - : ''; + ? `${s.originStation.label} → ${s.destinationStation.label}` + : null, + ].filter(Boolean); + const detail = parts.length ? ` (${parts.join(', ')})` : ''; const departure = s.scheduledDepartureDate - ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}` + ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleString('en-GB', { + timeZone: BATCH_TIMEZONE, + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + })} EAT` : ''; - return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`; + if (run) return `${run}${detail}${departure}`; + return s.reference + ? `train ${s.reference}${departure}` + : `${fallback}${detail}${departure}`; } catch (err) { this.logger.warn( `scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`, @@ -56,6 +87,51 @@ export class BookingNotifierService { } } + /** + * "train 8001 (voyage V-117)" for the departure a message is about, or null + * when nothing is known. Accepts the schedule row itself (preferred — callers + * that have just cancelled or detached the booking still hold it) or its id, + * falling back to the booking's own train_schedule_id. Never throws: a label + * lookup must not stop a notification going out. + */ + private async trainRun( + b: Booking, + schedule?: TrainRunSource | string | null, + ): Promise { + if (schedule && typeof schedule !== 'string') return trainRunLabel(schedule); + const scheduleId = schedule ?? b.trainScheduleId ?? null; + if (!scheduleId) return null; + try { + const s = await this.trainSchedules.findByIdWithStations(scheduleId); + return trainRunLabel(s); + } catch (err) { + this.logger.warn(`trainRun(${scheduleId}) failed: ${(err as Error).message}`); + return null; + } + } + + /** + * Resolve the run label, then build and send the SMS/email + in-app item. + * Fire-and-forget like every notifier method; `build` receives the label + * (null when unknown) and returns the message text. + */ + private withRun( + b: Booking, + schedule: TrainRunSource | string | null | undefined, + logLabel: string, + title: string, + build: (run: string | null) => string, + opts: { contact?: boolean; inApp?: Partial } = {}, + ): void { + void (async () => { + const msg = build(await this.trainRun(b, schedule)); + if (opts.contact !== false) await this.notifyContact(b, msg, logLabel); + this.inApp(b, title, msg, opts.inApp); + })().catch((err) => + this.logger.warn(`${logLabel} notification failed for ${this.ref(b)}: ${(err as Error).message}`), + ); + } + private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; } @@ -147,21 +223,30 @@ export class BookingNotifierService { } /** Train carrying the booking departed — dispatched origin → destination. */ - dispatched(b: Booking, origin: string | null, destination: string | null): void { - const msg = + dispatched( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'DISPATCHED', 'Shipment dispatched', (run) => `Your booking ${b.reference ?? b.id} has been dispatched` + - `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`; - void this.notifyContact(b, msg, 'DISPATCHED'); - this.inApp(b, 'Shipment dispatched', msg); + `${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}` + + `${run ? ` on ${run}` : ''}.`, + ); } /** Train carrying the booking arrived at destination. */ - arrived(b: Booking, origin: string | null, destination: string | null): void { - const msg = - `Your booking ${b.reference ?? b.id} has arrived` + - `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`; - void this.notifyContact(b, msg, 'ARRIVED'); - this.inApp(b, 'Shipment arrived', msg); + arrived( + b: Booking, + origin: string | null, + destination: string | null, + schedule?: TrainRunSource | string | null, + ): void { + this.withRun(b, schedule, 'ARRIVED', 'Shipment arrived', (run) => + `Your booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has arrived` + + `${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`, + ); } async payNow(b: Booking, deadline: Date): Promise { @@ -303,21 +388,28 @@ export class BookingNotifierService { ); } - displaced(b: Booking): void { - const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`; - void this.notifyContact(b, msg, 'DISPLACED'); - this.inApp(b, 'Booking displaced', msg); + displaced(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'DISPLACED', 'Booking displaced', (run) => + `Booking ${b.reference ?? b.id} was displaced${run ? ` from ${run}` : ''} by a government booking. ` + + `Move to another schedule or cancel.`, + ); } /** * Staff rescheduled the train carrying this booking to a new departure date. * The booking stays on the train — only the date moved. */ - rescheduled(b: Booking, newDeparture: Date): void { + rescheduled( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`; - void this.notifyContact(b, msg, 'RESCHEDULED'); - this.inApp(b, 'Booking rescheduled', msg); + this.withRun(b, schedule, 'RESCHEDULED', 'Booking rescheduled', (run) => + `Booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has been rescheduled` + + `${reasonClause(reason)}. New departure date: ${when}.`, + ); } /** @@ -325,49 +417,75 @@ export class BookingNotifierService { * the customer's original choice. In-app only — staff drove the change and * the allocation itself already notifies through the secured path. */ - allocatedOtherDay(b: Booking, newDeparture: Date): void { + allocatedOtherDay( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = - `Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` + - `New departure date: ${when}.`; - this.inApp(b, 'Booking allocated to another date', msg); + this.withRun( + b, + schedule, + 'ALLOCATED OTHER DAY', + 'Booking allocated to another date', + (run) => + `Booking ${b.reference ?? b.id} has been allocated to ${run ?? 'a train'} on a different date. ` + + `New departure date: ${when}.`, + { contact: false }, + ); } /** * Booking was removed from its train during a staff reschedule (not a government * pre-empt). It returns to eligible — the customer must rebook or reschedule. */ - removedFromTrain(b: Booking): void { - const msg = - `Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` + - `Please rebook or select a new schedule from the portal.`; - void this.notifyContact(b, msg, 'REMOVED FROM TRAIN'); - this.inApp(b, 'Removed from train', msg); + removedFromTrain(b: Booking, schedule?: TrainRunSource | string | null): void { + this.withRun(b, schedule, 'REMOVED FROM TRAIN', 'Removed from train', (run) => + `Booking ${b.reference ?? b.id} has been removed from ${run ?? 'its train'} during rescheduling. ` + + `Please rebook or select a new schedule from the portal.`, + ); } /** * The train carrying this booking was cancelled. The booking is detached and * returns to the eligible pool — the customer must rebook or pick a new schedule. */ - scheduleCancelled(b: Booking): void { - const msg = - `The train for booking ${b.reference ?? b.id} has been cancelled. ` + - `Your booking is not lost — please rebook or select a new schedule from the portal.`; - void this.notifyContact(b, msg, 'TRAIN CANCELLED'); + scheduleCancelled(b: Booking, schedule?: TrainRunSource | string | null): void { // HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email. - this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH }); + this.withRun( + b, + schedule, + 'TRAIN CANCELLED', + 'Train cancelled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} has been cancelled. ` + + `Your booking is not lost — please rebook or select a new schedule from the portal.`, + { inApp: { priority: NotificationPriority.HIGH } }, + ); } /** - * The train carrying this booking was moved for maintenance to a new departure - * date. The booking stays on the train — only the date moved. + * The train carrying this booking was moved (maintenance reschedule) to a new + * departure date. The booking stays on the train — only the date moved. The + * staff-entered reason is what the customer reads; "for maintenance" is only + * the fallback when none was typed. */ - maintenanceMoved(b: Booking, newDeparture: Date): void { + maintenanceMoved( + b: Booking, + newDeparture: Date, + schedule?: TrainRunSource | string | null, + reason?: string | null, + ): void { const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE }); - const msg = - `The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` + - `New departure date: ${when}.`; - void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE'); - this.inApp(b, 'Train maintenance reschedule', msg); + const why = reason?.trim() ? reasonClause(reason) : ' for maintenance'; + this.withRun( + b, + schedule, + 'MAINTENANCE RESCHEDULE', + 'Train rescheduled', + (run) => + `${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} was rescheduled${why}. ` + + `New departure date: ${when}.`, + ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 47d0a0d21..49a730e68 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -11,6 +11,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { trainRunLabel } from '../train-schedules/train-run-label.util'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { @@ -671,8 +672,11 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + // Name the departure by the schedule's train + voyage numbers (never the + // built train's name) so customers can match it to yard/customs paperwork. + const run = trainRunLabel(schedule); const msg = - `Booking is now open for the train departing ${depart}. ` + + `Booking is now open for ${run ?? 'the train'} departing ${depart}. ` + `Book your shipment from the portal home page before ${closes} EAT.`; const seenPhone = new Set(); @@ -797,8 +801,9 @@ export class BookingWindowService implements OnModuleInit { const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE, }); + const run = trainRunLabel(schedule, { capitalize: true }); const msgFor = (corridors: string[]) => - `A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` + + `${run ?? 'A train'} is scheduled on your intercity corridor ${corridors.join(', ')}, ` + `departing ${depart}. EDR will confirm once your cargo is placed on a train.`; // One inbox item per booking (its `data` is the once-per-booking marker diff --git a/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts new file mode 100644 index 000000000..c9ee445ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/checkpoint-leave-behind.spec.ts @@ -0,0 +1,268 @@ +import { BadRequestException } from "@nestjs/common"; + +import { TrainSchedulingService } from "./services/train-scheduling.service"; + +/** + * Mid-corridor leave-behind. Logging a pass at station N means the train has + * LEFT station N-1, so cargo that boarded back there has had its last chance + * to load: anything the operator did not tick rides no further and is + * unassigned back to the booking pool. + * + * Dispatch already does this for the origin yard; these cover the log-pass + * twin, plus the structured payload the UI needs to offer the + * EDR-fault / customer-fault cut on a part-loaded booking. + */ +describe("recordCheckpoint — mid-corridor leave-behind", () => { + const STATIONS = [ + { sequenceNo: 0, yardId: "yard-a", label: "Yard A" }, + { sequenceNo: 1, yardId: "yard-b", label: "Yard B" }, + { sequenceNo: 2, yardId: "yard-c", label: "Yard C" }, + ]; + + /** + * Exercises the leave-behind block in isolation — the surrounding + * recordCheckpoint does heavy graph/transaction work irrelevant here. + */ + const runLeaveBehind = async ( + dto: { sequenceNo: number; loadedBookingIds?: string[] }, + candidatesByYard: Record, + ) => { + const unassigned: Array<{ scheduleId: string; bookingId: string }> = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + unloadedBoarderIdsAtYard( + scheduleId: string, + yardId: string, + ): Promise; + unassignBooking( + scheduleId: string, + bookingId: string, + userId?: string, + ): Promise; + }; + svc.unloadedBoarderIdsAtYard = async ( + _scheduleId: string, + yardId: string, + ) => candidatesByYard[yardId] ?? []; + svc.unassignBooking = async (scheduleId: string, bookingId: string) => { + unassigned.push({ scheduleId, bookingId }); + }; + + // Mirrors the block inside recordCheckpoint. + if (dto.loadedBookingIds && dto.sequenceNo > 0) { + const departedYardId = STATIONS.find( + (s) => s.sequenceNo === dto.sequenceNo - 1, + )?.yardId; + if (departedYardId) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await svc.unloadedBoarderIdsAtYard( + "sched-1", + departedYardId, + ); + for (const bookingId of candidates.filter((id) => !keep.has(id))) { + await svc.unassignBooking("sched-1", bookingId, undefined); + } + } + } + return unassigned.map((u) => u.bookingId); + }; + + it("drops the unticked boarders of the yard the train just left", async () => { + // b4 and b5 boarded at Yard B; only b5 was loaded. Logging Yard C means + // the train has left B, so b4 is stranded and comes off the train. + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: ["b5"] }, + { "yard-b": ["b4", "b5"] }, + ); + expect(dropped).toEqual(["b4"]); + }); + + it("scopes the drop to the DEPARTED yard, never the one being logged", async () => { + // Cargo boarding at Yard C is not due until the train is there — logging + // the pass at C must not shed it. + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: [] }, + { "yard-b": [], "yard-c": ["b6", "b7"] }, + ); + expect(dropped).toEqual([]); + }); + + it("leaves nobody behind when the client omits the list", async () => { + // Older clients send no list — the historic behavior is that everyone rides. + const dropped = await runLeaveBehind( + { sequenceNo: 2 }, + { "yard-b": ["b4"] }, + ); + expect(dropped).toEqual([]); + }); + + it("does not shed at the origin — that is dispatch's decision", async () => { + const dropped = await runLeaveBehind( + { sequenceNo: 0, loadedBookingIds: [] }, + { "yard-a": ["b1", "b3"] }, + ); + expect(dropped).toEqual([]); + }); + + it("keeps every ticked booking on the train", async () => { + const dropped = await runLeaveBehind( + { sequenceNo: 2, loadedBookingIds: ["b4", "b5"] }, + { "yard-b": ["b4", "b5"] }, + ); + expect(dropped).toEqual([]); + }); +}); + +describe("assertNoPartiallyLoadedBookings — structured payload", () => { + const makeService = ( + rows: Array<{ + bookingId: string; + reference: string; + loaded: string; + total: string; + unloadedAllocationIds: string[]; + }>, + ) => { + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { + query: (sql: string, params: unknown[]) => Promise; + }; + assertNoPartiallyLoadedBookings( + schedule: unknown, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise; + }; + svc.dataSource = { query: async () => rows }; + return svc; + }; + const schedule = { + id: "sched-1", + trainSetId: "set-1", + originStationId: "yard-a", + }; + + it("carries the never-loaded allocation ids the fault-cut modal needs", async () => { + const svc = makeService([ + { + bookingId: "b4", + reference: "BK-2026-000853", + loaded: "1", + total: "4", + unloadedAllocationIds: ["w2", "w3", "w4"], + }, + ]); + + const err = await svc + .assertNoPartiallyLoadedBookings(schedule, "yard-b", { + action: "record this checkpoint", + yardLabel: "Yard B", + }) + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(BadRequestException); + const body = (err as BadRequestException).getResponse() as { + code: string; + message: string; + partiallyLoaded: { + yardLabel: string | null; + bookings: Array<{ + bookingId: string; + loadedWagons: number; + totalWagons: number; + unloadedAllocationIds: string[]; + }>; + }; + }; + + expect(body.code).toBe("PARTIALLY_LOADED_BOOKINGS"); + expect(body.partiallyLoaded.yardLabel).toBe("Yard B"); + expect(body.partiallyLoaded.bookings).toEqual([ + { + bookingId: "b4", + reference: "BK-2026-000853", + loadedWagons: 1, + totalWagons: 4, + unloadedAllocationIds: ["w2", "w3", "w4"], + }, + ]); + // The prose message survives for logs and older clients. + expect(body.message).toContain("BK-2026-000853 (1/4 wagons loaded)"); + }); + + it("stays silent when nothing at the yard is half-loaded", async () => { + const svc = makeService([]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, "yard-b", { + action: "dispatch", + }), + ).resolves.toBeUndefined(); + }); +}); + +/** + * Dispatch's origin auto-load. This UPDATE is the reason an unticked booking + * could still end up marked loaded: it stamps every PAID origin boarder, so + * without the confirmed-list guard a booking left attached (or one the + * unassign predicate cannot shed) rides as if its cargo were aboard. + */ +describe('dispatchSchedule — origin auto-load respects the confirmed list', () => { + /** Mirrors the `($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))` guard. */ + const wouldAutoLoad = (bookingId: string, confirmed: string[] | undefined) => + confirmed === undefined || confirmed.includes(bookingId); + + it('stamps only the ticked bookings', () => { + expect(wouldAutoLoad('b2', ['b2'])).toBe(true); + expect(wouldAutoLoad('b1', ['b2'])).toBe(false); + }); + + it('stamps nobody when the operator unticks everyone', () => { + expect(wouldAutoLoad('b1', [])).toBe(false); + }); + + it('keeps the historic auto-load for clients that send no list', () => { + expect(wouldAutoLoad('b1', undefined)).toBe(true); + expect(wouldAutoLoad('b2', undefined)).toBe(true); + }); +}); + +/** + * Empty wagons must travel with their train. + * + * The checkpoint position fix moves wagons by `current_train_schedule_id`, but + * dispatch used to bind only the PINNED slots (the ones carrying cargo). A + * built train rolls with its whole consist, so every empty wagon coupled to it + * was left unbound — and stayed recorded at the origin yard while the train it + * is hooked to travelled the corridor. + */ +describe('dispatchSchedule — the whole consist travels, not just loaded slots', () => { + /** Mirrors dispatch's binding set: pinned slots ∪ built-train consist. */ + const boundAtDispatch = ( + pinnedSlotWagonIds: Array, + builtTrainWagonIds: string[], + ) => [ + ...new Set([ + ...pinnedSlotWagonIds.filter((id): id is string => Boolean(id)), + ...builtTrainWagonIds, + ]), + ]; + + it('binds the empty wagons coupled to the built train', () => { + // The real shape of the reported schedule: 3 slots carry cargo, 45 empties + // ride along. All 48 must move when a checkpoint is logged. + const pinned = ['w1', 'w2', 'w3']; + const consist = ['w1', 'w2', 'w3', 'e1', 'e2', 'e3']; + const bound = boundAtDispatch(pinned, consist); + expect(bound).toEqual(['w1', 'w2', 'w3', 'e1', 'e2', 'e3']); + expect(bound).toContain('e1'); + }); + + it('never double-binds a wagon that is both pinned and on the train', () => { + const bound = boundAtDispatch(['w1', 'w1'], ['w1']); + expect(bound).toEqual(['w1']); + }); + + it('still binds pinned slots when there is no built train', () => { + // A set-only schedule (no Train row) has no consist to add. + expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 18904c032..44d0615af 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -6,10 +6,13 @@ import { IsBoolean, IsDateString, IsInt, + IsNotEmpty, IsNumber, IsOptional, + IsString, IsUUID, Max, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -119,6 +122,19 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; + @ApiProperty({ + example: 'V-2026-0620', + maxLength: 20, + description: + 'Voyage (sailing) number for this departure — the run identifier yards and ' + + 'customs quote. Required at creation; the UI pre-fills it with the built ' + + "train's direction-matched run number, but staff may override it.", + }) + @IsString() + @IsNotEmpty({ message: 'A voyage number is required' }) + @MaxLength(20) + voyageNumber!: string; + @ApiPropertyOptional({ format: 'uuid', description: 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 06c363bc4..d96a1db4a 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,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { TrainCheckpointKind } from '@edr/types'; +import { ApiProperty } from "@nestjs/swagger"; +import { TrainCheckpointKind } from "@edr/types"; import { IsArray, IsEnum, @@ -10,10 +10,12 @@ import { IsUUID, MaxLength, Min, -} from 'class-validator'; +} from "class-validator"; export class RecordCheckpointDto { - @ApiProperty({ description: 'Station position along the route (0 = origin).' }) + @ApiProperty({ + description: "Station position along the route (0 = origin).", + }) @IsInt() @Min(0) sequenceNo!: number; @@ -31,7 +33,7 @@ export class RecordCheckpointDto { @ApiProperty({ required: false, description: - 'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.', + "ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.", }) @IsOptional() @IsISO8601() @@ -42,7 +44,10 @@ export class RecordCheckpointDto { * loading and unloading time. All optional: a stop logged without them still * records its staying time. */ - @ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' }) + @ApiProperty({ + required: false, + description: "ISO timestamp; unloading start.", + }) @IsOptional() @IsISO8601() unloadingStartedAt?: string; @@ -62,6 +67,24 @@ export class RecordCheckpointDto { @IsISO8601() loadingCompletedAt?: string; + /** + * Mid-corridor leave-behind, the log-pass twin of DispatchScheduleDto's field. + * Recording THIS station means the train left the previous one, so the + * bookings that boarded back there have had their last chance to load. When + * present, only these ride on; every other unloaded boarder of the departed + * yard is deallocated from its wagon and returned to the booking pool. + * Absent (older clients) = nobody is left behind, the historic behavior. + */ + @ApiProperty({ + required: false, + description: + "Bookings from the yard just departed confirmed loaded; the rest are unassigned back to the pool. Omit to leave nobody behind.", + }) + @IsOptional() + @IsArray() + @IsUUID("4", { each: true }) + loadedBookingIds?: string[]; + @ApiProperty({ required: false }) @IsOptional() @IsString() @@ -73,7 +96,8 @@ export class RecordCheckpointDto { export class UpdateCheckpointDto { @ApiProperty({ required: false, - description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.', + description: + "ISO timestamp. Past allowed, future rejected, must be in corridor order.", }) @IsOptional() @IsISO8601() @@ -110,7 +134,8 @@ export class UpdateCheckpointDto { export class DispatchScheduleDto { @ApiProperty({ required: false, - description: 'Actual departure time; defaults to now. Past allowed, future rejected.', + description: + "Actual departure time; defaults to now. Past allowed, future rejected.", }) @IsOptional() @IsISO8601() @@ -125,10 +150,10 @@ export class DispatchScheduleDto { @ApiProperty({ required: false, description: - 'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.', + "Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.", }) @IsOptional() @IsArray() - @IsUUID('4', { each: true }) + @IsUUID("4", { each: true }) loadedBookingIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 9dc75335f..121dd5ed6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -439,6 +439,7 @@ export class IntercityService { id: booking.id, reference: booking.reference, status: booking.status, + paymentStatus: booking.paymentStatus ?? null, freightType: booking.freightType, isGovernment: booking.isGovernment, customer: booking.company?.name ?? 'Unknown customer', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index d6e778216..f61593442 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -508,6 +508,7 @@ describe('TrainSchedulingService', () => { const result = await service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: futureDeparture, + voyageNumber: 'V-TEST-1', locomotiveIds: ['loc-1', 'loc-2'], }); @@ -610,6 +611,7 @@ describe('TrainSchedulingService', () => { service.createContainerTrainSchedule({ routeId: 'route-1', scheduleDate: '2026-06-20T08:00:00.000Z', + voyageNumber: 'V-TEST-2', locomotiveIds: ['loc-1', 'loc-2'], }), ).rejects.toBeInstanceOf(ConflictException); @@ -1062,6 +1064,7 @@ describe('TrainSchedulingService', () => { const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({ sequenceNo, wagonNumber, + physicalWagonId: `wagon-id-${wagonNumber}`, physicalWagon: { wagonNumber }, wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 }, lengthMeters: 14, @@ -1134,7 +1137,7 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); - it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => { + it('drops a leg slot entirely from the import document — not part of the departing consist', () => { const loadList = { generatedAt: '2026-07-17T08:00:00.000Z', trainScheduleId: 'schedule-1', @@ -1176,15 +1179,67 @@ describe('TrainSchedulingService', () => { buildImportLoadListHtml: (l: unknown) => string; }).buildImportLoadListHtml(loadList); - expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT'); - // Departure station of the leg slot is its board yard, not the origin. - expect(html).toContain('Dire Dawa Port'); - // Only the origin-loaded container counts; the leg slot's tallies separately. + // The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all — + // it isn't on the departing consist. Only W-IMP appears. + expect(html).not.toContain('W-ICY'); + expect(html).not.toContain('ICY-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); expect(html).toContain('Total containers1'); - expect(html).toContain('To load en route1 containers'); }); - it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => { + it('drops a slot with no physical wagon pinned from the import document too', () => { + const loadList = { + generatedAt: '2026-07-17T08:00:00.000Z', + trainScheduleId: 'schedule-1', + trainNumber: '7002', + route: 'DCT/SGTD → GMP', + origin: 'DCT/SGTD', + destination: 'GMP', + totalBookings: 2, + wagons: [ + { + sequenceNo: 1, + // No physical wagon pinned (fleet shortfall, or a REAL cut nulled + // it out) — nothing physical to marshal, even though the slot + // still carries a LOADED allocation. + wagonNumber: null, + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'GHOST-001' }], + }, + ], + }, + { + sequenceNo: 2, + wagonNumber: 'W-IMP', + boardYard: null, + alightYard: null, + allocations: [ + { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }, + ], + }, + ], + operation: { status: {} }, + }; + + const html = (service as never as { + buildImportLoadListHtml: (l: unknown) => string; + }).buildImportLoadListHtml(loadList); + + expect(html).not.toContain('GHOST-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + + it('drops a leg slot entirely from the export document — not part of the departing consist', () => { const sizedAllocation = { ...loadedAllocation, containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], @@ -1204,9 +1259,33 @@ describe('TrainSchedulingService', () => { pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), }); - expect(html).toContain('TO LOAD AT DIRE DAWA PORT'); + // The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all. + expect(html).not.toContain('W-LEG'); + expect(html).toContain('Wagons1'); + expect(html).toContain('Total containers1'); + }); + + it('drops a whole-route slot with no physical wagon pinned from the export document too', () => { + const sizedAllocation = { + ...loadedAllocation, + containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], + }; + const ghost = { ...makeWagon(2, 'W-GHOST', [sizedAllocation]), id: 'slot-ghost', physicalWagonId: null }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, ghost] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml(schedule, {}); + + expect(html).not.toContain('W-GHOST'); + expect(html).toContain('Wagons1'); expect(html).toContain('Total containers1'); - expect(html).toContain('To load en route1 containers'); }); it('prints the consist-changes table for this stop, and omits it when there are none', () => { @@ -1240,6 +1319,44 @@ describe('TrainSchedulingService', () => { expect(withoutChanges).not.toContain('Consist Changed At This Stop'); }); + it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => { + const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }; + const legSlot = { + ...makeWagon(2, 'W-LEG', [loadedAllocation]), + id: 'slot-leg', + boardYardId: 'yard-dire', + alightYardId: 'yard-adama', + }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + originStation: { label: 'DCT/SGTD' }, + destinationStation: { label: 'GMP (Gelan Multipurpose Port)' }, + trainSet: { wagons: [wholeRoute, legSlot] }, + scheduleBookings: [], + }; + const build = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml.bind(service); + + const html = build(schedule, { + yardLabelById: new Map([ + ['yard-dire', 'Dire Dawa Port'], + ['yard-adama', 'Adama'], + ]), + }); + + expect(html).toContain('Departure Station'); + expect(html).toContain('Arrival Station'); + // Whole-route wagon: schedule's own endpoints. + expect(html).toContain('DCT/SGTD'); + expect(html).toContain('GMP (Gelan Multipurpose Port)'); + // Leg slot: its own board/alight yard, not the schedule's endpoints. + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + }); + it('lists loaded empty containers by number and states they are empty', () => { const schedule = { id: 'schedule-1', @@ -1404,6 +1521,46 @@ describe('TrainSchedulingService', () => { expect(numbers).toEqual(['W-LEG2']); }); + it('drops a whole-route slot with no physical wagon pinned, even though its allocation is LOADED', () => { + // A booking can hold a LOADED allocation before a real wagon backs it + // (fleet shortfall left the slot unpinned), or a REAL cut nulls + // physicalWagonId without ever touching the slot's own status. Either + // way there is no physical wagon standing there to marshal. + const pinned = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]); + const ghost = { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), physicalWagonId: null }; + const schedule = { trainSet: { wagons: [pinned, ghost] }, scheduleBookings: [] }; + + const { wagons } = onBoardView(schedule); + const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map( + (w) => w.physicalWagon.wagonNumber, + ); + expect(numbers).toEqual(['W-001']); + }); + + it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => { + // Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded + // at Adama, a LATER stop) read identically to intercityOnBoardView by + // the time this runs — both LOADED right now. Only the adjustment log + // knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp. + const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]); + const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' }; + const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' }; + const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] }; + + const { wagons } = onBoardView(schedule); + const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop + const wagonsAsOfStop = (service as never as { + wagonsAsOfStop: (w: unknown, s: Set) => Array<{ physicalWagon: { wagonNumber: string } }>; + }).wagonsAsOfStop.bind(service); + + const asOfDireDawa = wagonsAsOfStop(wagons, boardedByDireDawa); + expect(asOfDireDawa.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE']); + + const boardedByAdama = new Set(['W-DIRE', 'W-ADAMA']); // both stops have now happened + const asOfAdama = wagonsAsOfStop(wagons, boardedByAdama); + expect(asOfAdama.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE', 'W-ADAMA']); + }); + it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => { const rider = { id: 'booking-9', @@ -1796,6 +1953,8 @@ describe('TrainSchedulingService', () => { save: jest.fn().mockResolvedValue(undefined), create: jest.fn((x: unknown) => x), })), + // Wagon-history lookup of the released allocations' physical wagons. + query: jest.fn().mockResolvedValue([]), }; beforeEach(() => { 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 dbf0c7f7c..4f416cdcf 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 @@ -6,6 +6,7 @@ TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, WagonAllocationSnapshot, + WagonEventType, WagonMovementKind, WagonStatus, } from '@edr/types'; @@ -28,6 +29,7 @@ import { ILike, In, IsNull, + LessThanOrEqual, Not, QueryFailedError, Raw, @@ -71,6 +73,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -421,8 +424,28 @@ export class TrainSchedulingService { @Optional() @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService?: BookingBatchService, + // Per-wagon history ledger (global module). @Optional keeps the positional + // spec constructors working; production always has it. + @Optional() private readonly wagonHistory?: WagonHistoryService, ) {} + /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ + private async wagonsOfAllocations( + manager: EntityManager, + allocationIds: string[], + ): Promise> { + if (!allocationIds.length) return []; + return manager.query( + `SELECT a.id AS "allocationId", w.id AS "wagonId", w.wagon_number AS "wagonNumber", + w.current_yard_id AS "yardId", w.train_id AS "trainId" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id + JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE a.id = ANY($1::uuid[])`, + [allocationIds], + ); + } + /** * Notify each booking's customer that their shipment was dispatched / arrived, * with a deep-link to the booking. Fire-and-forget — never blocks the action. @@ -442,8 +465,11 @@ export class TrainSchedulingService { relations: { company: true }, }); for (const b of bookings) { - if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination); - else this.bookingNotifier.arrived(b, origin, destination); + if (event === 'dispatched') { + this.bookingNotifier.dispatched(b, origin, destination, schedule); + } else { + this.bookingNotifier.arrived(b, origin, destination, schedule); + } } } catch (err) { this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`); @@ -1169,7 +1195,7 @@ export class TrainSchedulingService { }); for (const booking of allocatedBookings) { if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue; - this.bookingNotifier.rescheduled(booking, departure); + this.bookingNotifier.rescheduled(booking, departure, schedule); notifiedCount += 1; } } @@ -1374,7 +1400,7 @@ export class TrainSchedulingService { .getRepository(Booking) .update(aboard.map((b) => b.id), { scheduledDate: departure } as never); for (const booking of aboard) { - this.bookingNotifier.maintenanceMoved(booking, departure); + this.bookingNotifier.maintenanceMoved(booking, departure, schedule, dto.reason); } } @@ -1871,6 +1897,10 @@ export class TrainSchedulingService { status: TrainScheduleStatusEnum.Scheduled, direction, trainNumber: pairTrainNumber ?? undefined, + // Staff-entered at creation; the UI defaults it to the built train's + // own voyage number (Train.trainName). Fall back to the pair train + // number here only for non-UI callers that send none. + voyageNumber: dto.voyageNumber?.trim() || pairTrainNumber || null, maxWagons, plannedWagonYards, reverseWagonOrder: dto.reverseWagonOrder ?? false, @@ -2320,12 +2350,18 @@ export class TrainSchedulingService { // batch fill, which unlinks it and frees its wagons on the next window cycle. const scheduledAt = new Date(); for (const booking of bookings) { + const wagonsRequired = sumWagonsRequired(booking, wagonPlan); await this.bookingsRepository.updateSchedulingFields( booking.id, { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt, - wagonsRequired: sumWagonsRequired(booking, wagonPlan), + wagonsRequired, + // Pinned for cancellation pricing: unassign clears wagonsRequired, + // this stays. Written once — re-allocation keeps the first stamp. + ...(Number(booking.cancellationWagons ?? 0) > 0 + ? {} + : { cancellationWagons: wagonsRequired }), }, manager, ); @@ -2408,6 +2444,21 @@ export class TrainSchedulingService { manager, ); await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager); + const carried = await this.wagonsOfAllocations(manager, allocationIds); + await this.wagonHistory?.record( + manager, + carried.map((c) => ({ + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + type: WagonEventType.BookingUnassigned, + actorUserId: userId ?? null, + fromYardId: c.yardId, + trainId: c.trainId, + trainScheduleId: scheduleId, + bookingId, + metadata: { allocationId: c.allocationId }, + })), + ); await manager.getRepository(WagonBookingAllocation).delete(allocationIds); } @@ -2474,6 +2525,18 @@ export class TrainSchedulingService { trainSetWagonId: null, status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, }); + await this.wagonHistory?.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.ReleasedFromSchedule, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + bookingId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + reason: 'Booking unassigned from the dispatched train', + }); } } await manager.getRepository(TrainSetWagon).delete(slot.id); @@ -2524,7 +2587,8 @@ export class TrainSchedulingService { .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { company: true } }); if (removedBooking && opts.notifyCustomer !== false) { - this.bookingNotifier.removedFromTrain(removedBooking); + // The booking's train_schedule_id is already cleared — name the run explicitly. + this.bookingNotifier.removedFromTrain(removedBooking, schedule); } this.logger.log( `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, @@ -2816,10 +2880,34 @@ export class TrainSchedulingService { // The pin lives ONLY on the schedule's slot — the Wagon entity keeps // its status untouched so other schedules can still use the wagon. + const previousPinId = slotById.get(assignment.trainSetWagonId)?.physicalWagonId ?? null; await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { physicalWagonId: assignment.physicalWagonId, status: 'RESERVED', }); + if (previousPinId !== assignment.physicalWagonId) { + const pinEvents: WagonEventInput[] = [ + { + wagonId: assignment.physicalWagonId, + type: WagonEventType.PinnedToSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + fromYardId: schedule.originStationId ?? null, + metadata: { slotId: assignment.trainSetWagonId, auto: false }, + }, + ]; + if (previousPinId) { + pinEvents.push({ + wagonId: previousPinId, + type: WagonEventType.UnpinnedFromSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + reason: 'Replaced on the slot', + metadata: { slotId: assignment.trainSetWagonId }, + }); + } + await this.wagonHistory?.record(manager, pinEvents); + } for (const [physicalId, slotId] of slotIdByPhysicalId) { if (slotId === assignment.trainSetWagonId) { slotIdByPhysicalId.delete(physicalId); @@ -2985,9 +3073,27 @@ export class TrainSchedulingService { } // The train is out — every pinned wagon is ASSIGNED to this schedule and // stays pinned so no other schedule can pick it while it's rolling. - const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? []) + const pinnedDispatchIds = (schedule.trainSet?.wagons ?? []) .map((slot) => slot.physicalWagonId) .filter((id): id is string => Boolean(id)); + // A built train rolls with its WHOLE consist, not just the slots that + // carry cargo: an empty wagon coupled to the train is physically leaving + // the yard too. Binding only the pinned slots left those empties behind + // on `current_train_schedule_id`, so the checkpoint position fix (which + // filters on exactly that column) never moved them and they stayed + // recorded at the origin yard while the train they are hooked to + // travelled the corridor. + const consistPhysicalIds = schedule.trainSet?.trainId + ? ( + await manager.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + select: { id: true }, + }) + ).map((w) => w.id) + : []; + const dispatchedPhysicalIds = [ + ...new Set([...pinnedDispatchIds, ...consistPhysicalIds]), + ]; if (dispatchedPhysicalIds.length) { await manager .getRepository(Wagon) @@ -2995,6 +3101,25 @@ export class TrainSchedulingService { { id: In(dispatchedPhysicalIds) }, { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId }, ); + const dispatchedWagons = await manager.getRepository(Wagon).find({ + where: { id: In(dispatchedPhysicalIds) }, + select: { id: true, wagonNumber: true, currentYardId: true, trainId: true }, + }); + await this.wagonHistory?.record( + manager, + dispatchedWagons.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.Dispatched, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: w.currentYardId ?? null, + trainId: w.trainId ?? schedule.trainSet?.trainId ?? null, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + metadata: { destinationYardId: schedule.destinationStationId ?? null }, + })), + ); } // Planned couples boarding at the ORIGIN join the built train now — the // departure is the moment they are physically hooked on. Mid-route @@ -3032,6 +3157,32 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); + await this.wagonHistory?.record(manager, [ + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: coupleYardId, + trainId: dispatchTrainId, + trainScheduleId: scheduleId, + toValue: maxSeq, + reason: 'Planned couple at the origin yard', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Dispatched, + occurredAt: now, + actorUserId: userId ?? null, + fromYardId: coupleYardId, + trainId: dispatchTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + }, + ]); await manager.getRepository(ScheduleWagonAdjustmentLog).save( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -3057,6 +3208,15 @@ export class TrainSchedulingService { // that the operator didn't load individually are auto-loaded now — the // train is leaving with them. Mid-corridor boarders stay PAID until the // operator loads them at their own yard. + // + // When the client sends the confirmed list, loading is a MANUAL decision: + // only the ticked bookings are stamped loaded. Anything unticked was + // already unassigned above, but a booking can also sit here unticked and + // still attached (government, or one this predicate cannot shed) — those + // must not be auto-loaded, or an empty wagon rides as if it carried cargo. + // Absent (older clients) = auto-load every origin boarder, the historic + // behavior. + const confirmedLoadedIds = dto.loadedBookingIds; await manager.query( `UPDATE freight.bookings b SET status = 'IN_TRANSIT', @@ -3068,8 +3228,14 @@ export class TrainSchedulingService { AND b.deleted_at IS NULL AND b.origin_yard_id = $2 AND b.loaded_at IS NULL - AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`, - [scheduleId, schedule.originStationId, now], + AND (b.payment_status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED')) + AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`, + [ + scheduleId, + schedule.originStationId, + now, + confirmedLoadedIds ? confirmedLoadedIds : null, + ], ); // Close the booking window; any still-pending (unallocated) reservations don't ride this train. await manager @@ -3167,11 +3333,19 @@ export class TrainSchedulingService { context: { action: string; yardLabel?: string }, ): Promise { if (!schedule.trainSetId) return; - const rows: Array<{ reference: string; loaded: string; total: string }> = - await this.dataSource.query( - `SELECT b.reference, + const rows: Array<{ + bookingId: string; + reference: string; + loaded: string; + total: string; + unloadedAllocationIds: string[]; + }> = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference, COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded, - COUNT(*) AS total + COUNT(*) AS total, + ARRAY_AGG(a.id) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) + AS "unloadedAllocationIds" FROM freight.wagon_booking_allocations a JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id JOIN freight.bookings b ON b.id = a.booking_id @@ -3183,18 +3357,38 @@ export class TrainSchedulingService { GROUP BY b.id, b.reference HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0 AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`, - [schedule.trainSetId, boardingYardId], - ); + [schedule.trainSetId, boardingYardId], + ); if (rows.length) { const detail = rows .map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`) .join(', '); const where = context.yardLabel ? ` at ${context.yardLabel}` : ''; - throw new BadRequestException( - `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + + // The message stays human-readable for logs and older clients, but the + // payload carries the machine-readable cut so the UI can offer the + // EDR-fault / customer-fault decision instead of parsing prose. + throw new BadRequestException({ + statusCode: 400, + error: 'Bad Request', + code: 'PARTIALLY_LOADED_BOOKINGS', + message: + `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + `or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` + `rebookable) first: ${detail}`, - ); + partiallyLoaded: { + scheduleId: schedule.id, + boardingYardId, + yardLabel: context.yardLabel ?? null, + action: context.action, + bookings: rows.map((r) => ({ + bookingId: r.bookingId, + reference: r.reference, + loadedWagons: Number(r.loaded), + totalWagons: Number(r.total), + unloadedAllocationIds: r.unloadedAllocationIds ?? [], + })), + }, + }); } } @@ -3221,6 +3415,20 @@ export class TrainSchedulingService { } } + /** + * The log-pass twin of {@link unloadedOriginBoarderIds}: bookings that boarded + * at `yardId` and are still unloaded once the train has left it. Same + * predicate — partially-loaded bookings (loading_started_at set) are excluded + * because assertNoPartiallyLoadedBookings resolves those first, and government + * bookings can never be shed. + */ + private async unloadedBoarderIdsAtYard( + scheduleId: string, + yardId: string, + ): Promise { + return this.unloadedOriginBoarderIds(scheduleId, yardId); + } + private async unloadedOriginBoarderIds( scheduleId: string, originYardId: string, @@ -3237,7 +3445,7 @@ export class TrainSchedulingService { AND b.loading_started_at IS NULL AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED' AND b.is_government = false - AND (b.status = 'PAID' + AND (b.payment_status = 'PAID' OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`, [scheduleId, originYardId], ); @@ -3351,7 +3559,7 @@ export class TrainSchedulingService { // milestone still counts as paid — the clearance views self-heal the row on // read, and the gate pass must not lag behind that. for (const booking of bookings) { - if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') { + if (booking.paymentStatus === 'PAID') { paidBookingIds.add(booking.id); } } @@ -3550,17 +3758,19 @@ export class TrainSchedulingService { // Leg slots couple mid-corridor — this origin document must say where their // cargo boards instead of listing it as loaded here (see the import list). - const slotYardLabels = await this.yardLabelsById( - (schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId), + // Also doubles as the per-row Departure/Arrival Station lookup below. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), ); const pendingBoardYardLabelBySlot = new Map( (schedule.trainSet?.wagons ?? []) .filter((wagon) => wagon.boardYardId) - .map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']), + .map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']), ); const html = this.buildExportLoadListHtml(schedule, { pendingBoardYardLabelBySlot, + yardLabelById, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); @@ -3593,6 +3803,13 @@ export class TrainSchedulingService { const wagons = (schedule.trainSet?.wagons ?? []) .filter((wagon) => { if (wagon.status === 'DEPARTED') return false; + // No physical wagon pinned to the slot — a booking can hold an + // allocation before a real wagon backs it (e.g. a fleet shortfall + // left it unpinned). There is nothing physical here to marshal, and + // a REAL cut also lands here: it nulls physicalWagonId without ever + // touching this slot's own status, so a cut wagon would otherwise + // linger as a phantom row with its cargo still listed. + if (!wagon.physicalWagonId) return false; const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED'); return wagon.boardYardId == null || hasLoaded; }) @@ -3617,6 +3834,24 @@ export class TrainSchedulingService { return { wagons, unassignedBookings }; } + /** + * Corrects intercityOnBoardView's CURRENT-state wagon list against a + * specific stop's document. intercityOnBoardView's "boardYardId == null || + * hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish + * "this leg slot coupled at THIS stop" from "it coupled at a LATER stop + * that has, by generation time, also already happened" (both look LOADED). + * Reprinting an earlier stop's document after a later one has run would + * otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the + * set of physical wagon numbers with a logged ADD at or before this stop + * (see marshallingDocumentAt) — the ground truth a real-time heuristic + * can't provide once multiple stops have already happened. + */ + private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set): TrainSetWagon[] { + return wagons.filter( + (wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''), + ); + } + /** * Every corridor stop where the consist actually changed for this schedule * (coupled, uncoupled, or switched — any flavor), in the order the train @@ -3711,11 +3946,37 @@ export class TrainSchedulingService { ); } - const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule); + // intercityOnBoardView's "boardYardId == null || hasLoaded" test reads + // CURRENT state — it can't tell "coupled here" from "coupled at a LATER + // stop that has since also happened" (both look LOADED by generation + // time once the trip has moved past this stop). Reprinting Marshalling 2 + // after Marshalling 3's stop already ran would otherwise show Marshalling + // 3's coupled wagons too. Correct it against the log: a leg-slot wagon + // belongs on THIS stop's document only if it actually has a logged ADD + // at or before THIS stop's own timestamp. + const boardedByThisStop = new Set( + ( + await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { + trainScheduleId: scheduleId, + action: 'ADD', + occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)), + }, + }) + ).map((row) => row.wagonNumber), + ); + const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop); const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ where: { trainScheduleId: scheduleId, yardId: stop.yardId }, order: { occurredAt: 'ASC' }, }); + // Per-row Departure/Arrival Station: a whole-route wagon reads the + // schedule's own origin/destination, a leg-slot wagon reads where IT + // boards/alights instead. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); const html = this.buildExportLoadListHtml(schedule, { title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, positionLabel: `At ${stop.yardLabel}`, @@ -3724,6 +3985,7 @@ export class TrainSchedulingService { emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), consistChangesAtStop: this.consistChangesAt(schedule, logRows), + yardLabelById, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); @@ -3763,6 +4025,9 @@ export class TrainSchedulingService { ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3770,6 +4035,7 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), + yardLabelById, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3839,6 +4105,10 @@ export class TrainSchedulingService { // Slots that couple to the train downstream (slot id → board yard label). // Their cargo renders as TO LOAD AT and stays out of the loaded tallies. pendingBoardYardLabelBySlot?: Map; + // yardId → label, for the per-row Departure/Arrival Station columns + // (falls back to the schedule's own origin/destination when a wagon's + // boardYardId/alightYardId is null — i.e. it rides the whole corridor). + yardLabelById?: Map; // Numbered marshalling docs only (see marshallingDocumentAt / // consistChangesAt) — couples/uncouples/switches logged at THIS stop. // Origin import/export docs never pass this, so they render no such box. @@ -3860,10 +4130,20 @@ export class TrainSchedulingService { const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); // The document is checked against the physical train, so it has to run in - // consist order — the relation comes back unordered. - const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( - (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), - ); + // consist order — the relation comes back unordered. Slots planned to + // couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only — + // intercity calls never pass it, their wagons list is already on-board + // only) are dropped here, not just tallied around: they are not part of + // the departing consist, so they get no row and no count on this document. + // Their own coupling shows up on THAT stop's own marshalling document. + const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])] + .filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id)) + // No physical wagon pinned to the slot (fleet shortfall left a booking's + // allocation unpinned, or a REAL cut nulled it out): nothing physical + // to marshal, so no row. Harmless no-op for the numbered docs, whose + // wagons list already went through intercityOnBoardView's own check. + .filter((wagon) => Boolean(wagon.physicalWagonId)) + .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)); // Empties sit on wagons that carry no booking allocation, keyed by the wagon // slot recorded when they were loaded. const emptiesByWagon = new Map(); @@ -3874,15 +4154,28 @@ export class TrainSchedulingService { empty, ]); } + const originLabel = schedule.originStation?.label ?? schedule.originStation?.code; + const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code; const rows = wagons .flatMap((wagon) => { + // Departure/Arrival Station per row: a leg-slot wagon boards/alights + // somewhere other than the schedule's own endpoints; a whole-route + // wagon just reads origin/destination. + const departureLabel = wagon.boardYardId + ? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route') + : originLabel; + const arrivalLabel = wagon.alightYardId + ? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route') + : destinationLabel; // Wagon identity is the same on every row the wagon produces, loaded or not. const wagonCells = `${esc(wagon.sequenceNo)} ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} - ${esc(Number(wagon.capacityTons || 0).toFixed(3))}`; + ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${esc(departureLabel)} + ${esc(arrivalLabel)}`; const allocations = wagon.allocations ?? []; // An empty wagon still runs in the consist, so it still gets a line. Staff // check this document against the physical train — a wagon with no row @@ -3906,11 +4199,10 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } - const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id); return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; @@ -3922,7 +4214,7 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -3935,7 +4227,7 @@ export class TrainSchedulingService { // they are still physically on the train, so they get rows of their own. const unassigned = opts?.unassignedBookings ?? []; const unassignedRows = unassigned.length - ? `ON BOARD — WAGON NOT RECORDED` + + ? `ON BOARD — WAGON NOT RECORDED` + unassigned .map((booking) => { const containerNumbers = (booking.bookingContainers ?? []) @@ -3944,7 +4236,7 @@ export class TrainSchedulingService { .join(', '); const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; return ` - ${esc(booking.reference)} — ${esc(leg)} + ${esc(booking.reference)} — ${esc(leg)} ${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)} ${esc(booking.company?.name)} ${esc(containerNumbers)} @@ -3959,27 +4251,20 @@ export class TrainSchedulingService { (wagon.allocations ?? []).length === 0 && !emptiesByWagon.get(Number(wagon.sequenceNo))?.length, ).length; - const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id); const totalWeight = wagons.reduce( (sum, wagon) => - sum + - (loadsHere(wagon) - ? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) - : 0), + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); // Container count summary (40ft, 20ft) — empties returning to Djibouti are - // physically on the train, so they count, and are called out on their own tile. - // Cargo boarding downstream is not on this train yet — it tallies separately. - let count40ft = 0, count20ft = 0, pendingContainers = 0; + // physically on the train, so they count, and are called out on their own + // tile. Cargo boarding downstream never enters this loop — `wagons` above + // already excludes those slots. + let count40ft = 0, count20ft = 0; wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - if (!loadsHere(wagon)) { - pendingContainers++; - return; - } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -4047,7 +4332,6 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
- ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''} ${emptyContainers.length ? `
Empty containers${esc(emptyContainers.length)}
` : ''}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
@@ -4093,6 +4377,8 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity + Departure Station + Arrival Station Cargo Type Company Container No @@ -4101,7 +4387,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} ${unassignedRows} @@ -4247,30 +4533,26 @@ export class TrainSchedulingService { .replace(/'/g, '''); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const status = loadList.operation.status; - // A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the - // physical train this Djibouti-side document is checked against, so it must - // stay out of the loaded tallies or the gate count stops matching. - const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard; - const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); - const totalWeight = loadList.wagons.reduce( - (sum, wagon) => - sum + - (loadsHere(wagon) - ? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) - : 0), + // A leg slot (boardYard set) couples mid-corridor — it is not part of the + // consist this Djibouti-side document is checked against yet, so it gets + // no row and no count here at all. Its own coupling shows up on THAT + // stop's own marshalling document once it actually happens. Same for a + // slot with no physical wagon pinned at all — a booking can hold an + // allocation before a real wagon backs it (fleet shortfall), or a REAL + // cut nulled it out; either way there is nothing physical to marshal. + const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null); + const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); + const totalWeight = wagons.reduce( + (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); - const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length; - // Container count summary (40ft, 20ft) — loaded at origin vs. en route - let count40ft = 0, count20ft = 0, pendingContainers = 0; - loadList.wagons.forEach((wagon) => { + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + wagons.forEach((wagon) => { wagon.allocations.forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - if (!loadsHere(wagon)) { - pendingContainers++; - return; - } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -4278,7 +4560,7 @@ export class TrainSchedulingService { }); }); - const allocationRows = loadList.wagons + const allocationRows = wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} ${esc(wagon.wagonNumber)} @@ -4311,7 +4593,7 @@ export class TrainSchedulingService { ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(sealNumbers || '-')} - ${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -4378,13 +4660,12 @@ export class TrainSchedulingService {
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
-
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
+
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
- ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -4918,6 +5199,24 @@ export class TrainSchedulingService { // skipped checkpoint log cannot smuggle an unresolved yard past the gate. await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo); + // Mid-corridor leave-behind. Recording THIS station means the train has + // left the previous one, so cargo that boarded back there has had its last + // chance to load: anything the operator did not tick is deallocated and + // returned to the pool, exactly as dispatch does for the origin yard. + // Origin (seq 0) is dispatch's job, so only seq >= 1 has a departed yard. + if (dto.loadedBookingIds && dto.sequenceNo > 0) { + const departedYardId = stations.find( + (s) => s.sequenceNo === dto.sequenceNo - 1, + )?.yardId; + if (departedYardId) { + const keep = new Set(dto.loadedBookingIds); + const candidates = await this.unloadedBoarderIdsAtYard(scheduleId, departedYardId); + for (const bookingId of candidates.filter((id) => !keep.has(id))) { + await this.unassignBooking(scheduleId, bookingId, undefined); + } + } + } + // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo }, @@ -5004,6 +5303,7 @@ export class TrainSchedulingService { ); const adjustmentRows: ScheduleWagonAdjustmentLog[] = []; const movementRows: WagonMovement[] = []; + const historyRows: WagonEventInput[] = []; let realCutHappened = false; for (const [wagonId, cutYardId] of cutNow) { const wagon = cutWagonById.get(wagonId); @@ -5011,6 +5311,31 @@ export class TrainSchedulingService { if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue; if (realCutIds.has(wagonId) && builtTrainId) { // REAL cut: the built train permanently loses the wagon here. + historyRows.push( + { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null, + toYardId: cutYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Available, + metadata: { permanent: true }, + }, + { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt, + fromYardId: cutYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + fromValue: wagon.sequenceNumber, + reason: 'Cut from the train at this yard (permanent)', + }, + ); await manager.getRepository(Wagon).update(wagonId, { currentYardId: cutYardId, currentTrainScheduleId: null, @@ -5043,6 +5368,18 @@ export class TrainSchedulingService { realCutHappened = true; } else { // Soft cut: sits out the rest of this trip, stays in the build. + historyRows.push({ + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt, + fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null, + toYardId: cutYardId, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { permanent: false }, + }); await manager.getRepository(Wagon).update(wagonId, { currentYardId: cutYardId, currentTrainScheduleId: null, @@ -5067,6 +5404,7 @@ export class TrainSchedulingService { if (movementRows.length) { await manager.getRepository(WagonMovement).save(movementRows); } + await this.wagonHistory?.record(manager, historyRows); // Keep the coupling order gapless after permanent removals. if (realCutHappened && builtTrainId) { const remaining = await manager.getRepository(Wagon).find({ @@ -5120,6 +5458,18 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId, }); + await this.wagonHistory?.record(manager, { + wagonId, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt, + fromYardId: coupleYardId, + trainId: builtTrainId, + trainScheduleId: scheduleId, + toValue: maxSeq, + reason: 'Planned couple at a mid-route stop', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }); coupleLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -5137,6 +5487,20 @@ export class TrainSchedulingService { await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows); } } + // Which wagons the position fix below will actually move — read first + // so each gets its own PASSED_CHECKPOINT history row (from → to yard). + const riding = await manager + .getRepository(Wagon) + .createQueryBuilder('w') + .select(['w.id', 'w.wagonNumber', 'w.currentYardId', 'w.trainId']) + .where('w.current_train_schedule_id = :scheduleId', { scheduleId }) + .andWhere('(w.current_yard_id IS NULL OR w.current_yard_id IN (:...passedYardIds))', { + passedYardIds, + }) + .andWhere('w.current_yard_id IS DISTINCT FROM :stationYardId', { + stationYardId: station.yardId, + }) + .getMany(); // Leg slots (booking legs boarding/alighting mid-corridor — see // stampSlotLegs) reaching their board/alight yard here: logged same as @@ -5219,6 +5583,20 @@ export class TrainSchedulingService { passedYardIds, }) .execute(); + await this.wagonHistory?.record( + manager, + riding.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.PassedCheckpoint, + occurredAt, + fromYardId: w.currentYardId ?? null, + toYardId: station.yardId, + trainId: w.trainId ?? schedule.trainSet?.trainId ?? null, + trainScheduleId: scheduleId, + metadata: { sequenceNo: dto.sequenceNo, kind: dto.kind ?? null }, + })), + ); if (schedule.trainSet?.trainId) { await manager .getRepository(Train) @@ -5445,6 +5823,7 @@ export class TrainSchedulingService { ); const arrivalLogRows: ScheduleWagonAdjustmentLog[] = []; const arrivalMovementRows: WagonMovement[] = []; + const arrivalHistoryRows: WagonEventInput[] = []; for (const slot of schedule.trainSet?.wagons ?? []) { if (!slot.physicalWagonId) continue; const wagon = settleWagonById.get(slot.physicalWagonId); @@ -5468,6 +5847,32 @@ export class TrainSchedulingService { // Arrival fallback for a journey logged without mid-route // checkpoints: the REAL cut still permanently removes the wagon // from the built train at its cut yard. + arrivalHistoryRows.push( + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CutAtYard, + occurredAt: now, + fromYardId: slot.boardYardId ?? schedule.originStationId ?? null, + toYardId: settleYardId, + trainId: ownerTrainId, + trainScheduleId: scheduleId, + bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null, + toValue: WagonStatus.Available, + metadata: { permanent: true, atArrival: true }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt: now, + fromYardId: settleYardId, + trainId: ownerTrainId, + trainScheduleId: scheduleId, + fromValue: wagon.sequenceNumber, + reason: 'Cut from the train at its planned yard (permanent)', + }, + ); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5497,6 +5902,19 @@ export class TrainSchedulingService { }), ); } else { + arrivalHistoryRows.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: slot.boardYardId ?? schedule.originStationId ?? null, + toYardId: settleYardId, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { slotId: slot.id, loaded: (slot.allocations ?? []).length > 0 }, + }); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5548,6 +5966,18 @@ export class TrainSchedulingService { if (!wagon) continue; if (wagon.currentTrainScheduleId === scheduleId) { // Joined during the trip, slot-less: settle at the destination. + arrivalHistoryRows.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId ?? null, + trainId: wagon.trainId ?? null, + trainScheduleId: scheduleId, + toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, + metadata: { loaded: false, coupledMidRoute: true }, + }); await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5585,6 +6015,32 @@ export class TrainSchedulingService { status: WagonStatus.Assigned, currentYardId: schedule.destinationStationId, }); + arrivalHistoryRows.push( + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + occurredAt: now, + fromYardId: coupleYardId, + trainId: arrivalTrainId, + trainScheduleId: scheduleId, + toValue: arrivalMaxSeq, + reason: 'Planned couple joined on arrival', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }, + { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: coupleYardId, + toYardId: schedule.destinationStationId ?? null, + trainId: arrivalTrainId, + trainScheduleId: scheduleId, + toValue: WagonStatus.Assigned, + metadata: { loaded: false, coupledMidRoute: true }, + }, + ); arrivalLogRows.push( manager.getRepository(ScheduleWagonAdjustmentLog).create({ trainScheduleId: scheduleId, @@ -5609,9 +6065,43 @@ export class TrainSchedulingService { ); } } + // Consist-only empties: coupled to the built train and bound at dispatch + // so the checkpoint position fix moves them, but they own no slot, so the + // per-slot settle above never sees them. Release them here or they stay + // locked to a finished schedule and no later train can pick them up. + // They carry no cargo, so they simply settle where the train ended up. + const looseEmpties = await manager.getRepository(Wagon).find({ + where: { currentTrainScheduleId: scheduleId }, + select: { id: true, wagonNumber: true, currentYardId: true, trainId: true }, + }); + arrivalHistoryRows.push( + ...looseEmpties.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.SettledOnArrival, + occurredAt: now, + fromYardId: w.currentYardId ?? null, + toYardId: schedule.destinationStationId ?? null, + trainId: w.trainId ?? null, + trainScheduleId: scheduleId, + metadata: { loaded: false, consistOnly: true }, + })), + ); + await manager + .getRepository(Wagon) + .createQueryBuilder() + .update(Wagon) + .set({ + currentTrainScheduleId: null, + trainSetWagonId: null, + currentYardId: schedule.destinationStationId, + }) + .where('current_train_schedule_id = :scheduleId', { scheduleId }) + .execute(); if (arrivalLogRows.length) { await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows); } + await this.wagonHistory?.record(manager, arrivalHistoryRows); if (arrivalMovementRows.length) { await manager.getRepository(WagonMovement).save(arrivalMovementRows); } @@ -5808,6 +6298,19 @@ export class TrainSchedulingService { } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { + await this.wagonHistory?.record(manager, { + wagonId: wagon.physicalWagonId, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.ReturnedOnCancel, + actorUserId: userId ?? null, + fromYardId: wagon.physicalWagon?.currentYardId ?? null, + toYardId: schedule.originStationId ?? null, + trainId: wagon.physicalWagon?.trainId ?? null, + trainScheduleId: id, + toValue: wagon.physicalWagon?.trainId ? WagonStatus.Assigned : WagonStatus.Available, + reason: dto?.reason?.trim() || 'Schedule cancelled', + metadata: { slotId: wagon.id }, + }); await manager.getRepository(Wagon).update(wagon.physicalWagonId, { currentTrainScheduleId: null, trainSetWagonId: null, @@ -5844,7 +6347,8 @@ export class TrainSchedulingService { const booking = await this.bookingsRepository .findByIdWithFiles(sb.bookingId) .catch(() => null); - if (booking) this.bookingNotifier.scheduleCancelled(booking); + // Detached above, so pass the cancelled schedule for its train/voyage numbers. + if (booking) this.bookingNotifier.scheduleCancelled(booking, schedule); } // Window retired (DONE) — remove the card from portal/GL lists right away. @@ -6784,6 +7288,15 @@ export class TrainSchedulingService { physicalWagonId: physical.id, status: 'RESERVED', }); + await this.wagonHistory?.record(manager, { + wagonId: physical.id, + wagonNumber: physical.wagonNumber, + type: WagonEventType.PinnedToSchedule, + trainScheduleId: scheduleId, + trainId: builtTrainId ?? null, + fromYardId: physical.currentYardId ?? null, + metadata: { slotId: slot.trainSetWagonId, auto: true }, + }); const pinnedSpans = occupiedSpans.get(physical.id) ?? []; pinnedSpans.push(span); occupiedSpans.set(physical.id, pinnedSpans); @@ -8860,6 +9373,21 @@ export class TrainSchedulingService { for (const wagon of removed) { await manager.getRepository(Wagon).update(wagon.id, detachPatch); } + const consistReason = (dto as { reason?: string | null }).reason?.trim() || null; + await this.wagonHistory?.record( + manager, + removed.map((wagon) => ({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: currentYardId ?? null, + fromValue: wagon.sequenceNumber, + reason: consistReason ?? 'Trimmed from the consist on the schedule', + })), + ); if (removed.length && ownSetIds.length) { // This train's own pins (all its runs) on trimmed wagons are stale — // clear them so the freed wagon isn't still claimed by slots it left. @@ -8897,6 +9425,32 @@ export class TrainSchedulingService { // Mirror on the in-memory row — the compaction below sorts by it. to.sequenceNumber = from.sequenceNumber; await manager.getRepository(Wagon).update(from.id, detachPatch); + await this.wagonHistory?.record(manager, [ + { + wagonId: to.id, + wagonNumber: to.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: to.currentYardId ?? null, + toValue: from.sequenceNumber, + reason: consistReason ?? `Switched in for ${from.wagonNumber}`, + metadata: { replaced: from.wagonNumber, replacedWagonId: from.id }, + }, + { + wagonId: from.id, + wagonNumber: from.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: currentYardId ?? null, + fromValue: from.sequenceNumber, + reason: consistReason ?? `Switched out for ${to.wagonNumber}`, + metadata: { replacedBy: to.wagonNumber, replacedByWagonId: to.id }, + }, + ]); } const remaining = consist.filter( @@ -8912,6 +9466,7 @@ export class TrainSchedulingService { } } let sequence = compacted.length; + const addedEvents: WagonEventInput[] = []; for (const wagon of added) { sequence += 1; await manager.getRepository(Wagon).update(wagon.id, { @@ -8919,7 +9474,20 @@ export class TrainSchedulingService { sequenceNumber: sequence, status: WagonStatus.Assigned, }); + addedEvents.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + trainScheduleId: scheduleId, + fromYardId: wagon.currentYardId ?? null, + toValue: sequence, + reason: consistReason ?? 'Added to the consist on the schedule', + metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } }, + }); } + await this.wagonHistory?.record(manager, addedEvents); // The schedule is full when every consist wagon is allocated. await manager @@ -10448,6 +11016,8 @@ export class TrainSchedulingService { // without the wagons' tare. The legs tab shows this per booking. cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0, status: sb.booking?.status ?? null, + // Loadability is decided by the payment status, not `status`. + paymentStatus: sb.booking?.paymentStatus ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, // Which leg of the corridor this booking rides — the workspace can't @@ -11443,6 +12013,30 @@ export class TrainSchedulingService { await allocs.update(alloc.id, { trainSetWagonId: created.id }); } await slotRepo.update(source.id, emptyLoadFields); + await this.wagonHistory?.record(manager, [ + ...(source.physicalWagonId + ? [ + { + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + toValue: consistWagon.wagonNumber, + metadata: { toWagonId: consistWagon.id, allocations: sourceAllocs.length }, + }, + ] + : []), + { + wagonId: consistWagon.id, + wagonNumber: consistWagon.wagonNumber, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + fromValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length }, + }, + ]); return; } @@ -11457,6 +12051,54 @@ export class TrainSchedulingService { } await slotRepo.update(target.id, sourceLoadFields); await slotRepo.update(source.id, targetLoadFields); + const moveEvents: WagonEventInput[] = []; + if (source.physicalWagonId) { + moveEvents.push({ + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + toValue: target.physicalWagon?.wagonNumber ?? null, + metadata: { toWagonId: target.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 }, + }); + } + if (target.physicalWagonId) { + moveEvents.push({ + wagonId: target.physicalWagonId, + wagonNumber: target.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: sourceAllocs[0]?.bookingId ?? null, + fromValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 }, + }); + } + if (targetAllocs.length) { + if (target.physicalWagonId) { + moveEvents.push({ + wagonId: target.physicalWagonId, + wagonNumber: target.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedOut, + trainScheduleId: scheduleId, + bookingId: targetAllocs[0]?.bookingId ?? null, + toValue: source.physicalWagon?.wagonNumber ?? null, + metadata: { toWagonId: source.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true }, + }); + } + if (source.physicalWagonId) { + moveEvents.push({ + wagonId: source.physicalWagonId, + wagonNumber: source.physicalWagon?.wagonNumber ?? null, + type: WagonEventType.LoadMovedIn, + trainScheduleId: scheduleId, + bookingId: targetAllocs[0]?.bookingId ?? null, + fromValue: target.physicalWagon?.wagonNumber ?? null, + metadata: { fromWagonId: target.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true }, + }); + } + } + await this.wagonHistory?.record(manager, moveEvents); }); return this.getTrainScheduleById(scheduleId); @@ -11761,7 +12403,11 @@ export class TrainSchedulingService { return assignability.shortage; } - /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ + /** + * Paid (or government) bookings that may be loaded onto wagons — excludes + * expired / awaiting payment. "Paid" is read from the PAYMENT status only; + * the booking status is not a reliable payment signal. + */ private isReadyToLoadBooking(booking: { status: string; paymentStatus?: string | null; @@ -11771,7 +12417,7 @@ export class TrainSchedulingService { if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') { return false; } - if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true; + if (booking.paymentStatus === 'PAID') return true; if (booking.isGovernment) return true; return false; } @@ -12151,6 +12797,12 @@ export class TrainSchedulingService { // 2. The physical wagons follow the train — the target's stay put, and // EVERY wagon on the source train (coupled or loose) moves across so // nothing strands on the deactivated train. + const mergedFromSource = sourceTrainId + ? await manager.getRepository(Wagon).find({ + where: { trainId: sourceTrainId }, + select: { id: true, wagonNumber: true, currentYardId: true }, + }) + : []; if (incomingWagons.length) { await manager.getRepository(Wagon).update( { id: In(incomingWagons.map((w) => w.id)) }, @@ -12162,6 +12814,20 @@ export class TrainSchedulingService { .getRepository(Wagon) .update({ trainId: sourceTrainId }, { trainId: targetTrain.id }); } + await this.wagonHistory?.record( + manager, + mergedFromSource.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.TrainMerged, + fromYardId: w.currentYardId ?? null, + trainId: targetTrain.id, + trainScheduleId: schedule.id, + fromValue: sourceTrainId, + toValue: targetTrain.code, + reason: `Train merged into ${targetTrain.code}`, + })), + ); // 3. Carry the target's train-set wagon rows into THIS consist, appended // after the existing wagons. Sequence is provisional — staff reorder diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 7b28a8582..8160127b0 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -1,4 +1,4 @@ -import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; +import { Freight, WagonEventType, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, ConflictException, @@ -19,11 +19,13 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; @@ -75,6 +77,7 @@ export class TrainBuilderService { constructor( private readonly dataSource: DataSource, private readonly bookingBatchService: BookingBatchService, + private readonly wagonHistory: WagonHistoryService, ) {} async buildTrain(dto: BuildTrainDto) { @@ -133,7 +136,7 @@ export class TrainBuilderService { await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); if (dto.wagonIds?.length) { - await this.attachWagons(manager, train, dto.wagonIds, 0); + await this.attachWagons(manager, train, dto.wagonIds, 0, null); } return train.id; }); @@ -504,7 +507,7 @@ export class TrainBuilderService { if (locomotiveIds.length < 1) { throw new BadRequestException('A train must be pulled by at least one locomotive'); } - await this.dataSource.transaction(async (manager) => { + const pending = await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const yard = await manager .getRepository(Yard) @@ -523,10 +526,76 @@ export class TrainBuilderService { await manager .getRepository(Train) .update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); + // Capacity math reads the SET's locomotives, not the train's — push the + // new pull weight onto the live runs too, or they keep the old ceiling. + return this.syncLiveSchedulesAfterLocomotiveChange(manager, train.id, locomotiveIds); }); + // Re-derive FULL/reopen once committed — a bigger pull weight can free room + // on a schedule that had closed as FULL. + await this.reconcileWindowsAfterConsistChange(pending); return this.getComposition(id); } + /** + * Mirror a built train's locomotive change onto every LIVE (DRAFT/SCHEDULED) + * schedule formed from it. The three capacity axes are derived from + * `train_set_locomotives` (see trainSetLocomotiveLimits), which is snapshotted + * when the set is built and never re-synced — so adding a second locomotive + * raised `trains.capacity_tons` but left every existing schedule pulling on + * the old single-loco ceiling, still refusing bookings for want of weight. + * + * Only DRAFT/SCHEDULED runs follow the live train; DISPATCHED/ARRIVED render + * from their frozen snapshot and must not be disturbed (same rule as + * syncLiveScheduleAfterConsistChange). + */ + private async syncLiveSchedulesAfterLocomotiveChange( + manager: EntityManager, + trainId: string, + locomotiveIds: string[], + ): Promise { + const trainSets = await manager.getRepository(TrainSet).find({ where: { trainId } }); + if (!trainSets.length) return []; + + const schedules = await manager.getRepository(TrainSchedule).find({ + where: { + trainSetId: In(trainSets.map((s) => s.id)), + status: In(['DRAFT', 'SCHEDULED']), + }, + }); + if (!schedules.length) return []; + + // Only the sets still backing a live run — a set behind an ARRIVED schedule + // keeps the locomotives it actually ran with. + const liveSetIds = [...new Set(schedules.map((s) => s.trainSetId))]; + const [primaryId] = locomotiveIds; + for (const trainSetId of liveSetIds) { + await manager.getRepository(TrainSetLocomotive).delete({ trainSetId }); + await manager.getRepository(TrainSetLocomotive).save( + locomotiveIds.map((locomotiveId, index) => + manager + .getRepository(TrainSetLocomotive) + .create({ trainSetId, locomotiveId, sequenceNo: index }), + ), + ); + // `locomotiveId` is the primary-locomotive fallback for single-loco reads. + await manager.getRepository(TrainSet).update(trainSetId, { locomotiveId: primaryId }); + } + + return schedules.map((s) => ({ + scheduleId: s.id, + wasFull: s.bookingWindowStatus === 'FULL', + })); + } + + /** {@link reconcileWindowAfterConsistChange} over several schedules. */ + private async reconcileWindowsAfterConsistChange( + pending: PendingWindowCheck[], + ): Promise { + for (const check of pending) { + await this.reconcileWindowAfterConsistChange(check); + } + } + /** * Edit a built train's display identity: name and fixed import/export run * numbers. Mirrors the build-time number rules — the pair may not collide @@ -617,8 +686,19 @@ export class TrainBuilderService { wagon.currentYardId === previousYardId, ); const now = new Date(); + const events: WagonEventInput[] = []; for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedWithTrain, + occurredAt: now, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: `Train ${train.code} relocated`, + }); await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); // Ledger row keeps the wagon's yard history auditable (mirrors the // manual-relocation path in the wagons service). @@ -632,6 +712,7 @@ export class TrainBuilderService { }), ); } + await this.wagonHistory.record(manager, events); }); return this.getComposition(id); } @@ -668,6 +749,16 @@ export class TrainBuilderService { occurredAt: new Date(), }), ); + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: 'Coupled wagon moved from the train builder', + }); }); return this.getComposition(id); } @@ -720,6 +811,19 @@ export class TrainBuilderService { await manager .getRepository(Wagon) .update(moving.map((w) => w.id), { currentYardId: yard.id }); + await this.wagonHistory.record( + manager, + moving.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: w.currentYardId ?? null, + toYardId: yard.id, + trainId: train.id, + reason: 'Coupled wagons moved from the train builder', + })), + ); await manager.getRepository(WagonMovement).save( moving.map((w) => manager.getRepository(WagonMovement).create({ @@ -743,7 +847,7 @@ export class TrainBuilderService { const currentCount = await manager .getRepository(Wagon) .count({ where: { trainId: train.id } }); - const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount); + const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount, userId ?? null); return this.syncLiveScheduleAfterConsistChange( manager, train.id, @@ -882,6 +986,18 @@ export class TrainBuilderService { }), ); } + await this.wagonHistory.record(manager, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.StatusChanged, + actorUserId: userId ?? null, + trainId: train.id, + fromYardId: wagon.currentYardId ?? train.currentYardId ?? null, + fromValue: previousStatus, + toValue: WagonStatus.Maintenance, + reason: note?.trim() || null, + metadata: { trainCode: train.code }, + }); // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history // surface, and a maintenance detach has to be in it. @@ -1099,6 +1215,22 @@ export class TrainBuilderService { for (let i = 0; i < dto.wagonIds.length; i++) { await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); } + const previousSeq = new Map(wagons.map((w) => [w.id, w])); + await this.wagonHistory.record( + manager, + dto.wagonIds + .map((wid, i) => ({ wagon: previousSeq.get(wid), to: i + 1 })) + .filter((x) => x.wagon && x.wagon.sequenceNumber !== x.to) + .map(({ wagon, to }) => ({ + wagonId: wagon!.id, + wagonNumber: wagon!.wagonNumber, + type: WagonEventType.SequenceChanged, + trainId: train.id, + fromValue: wagon!.sequenceNumber, + toValue: to, + reason: 'Consist reordered', + })), + ); // Propagate the new order to every live (DRAFT/SCHEDULED) schedule of // this train: slots pinned to a reordered wagon adopt the wagon's new @@ -1220,6 +1352,10 @@ export class TrainBuilderService { 'Train has active schedules; cancel them before disbanding the train', ); } + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + select: { id: true, wagonNumber: true, currentYardId: true, sequenceNumber: true, status: true }, + }); await manager .getRepository(Wagon) .update( @@ -1232,6 +1368,19 @@ export class TrainBuilderService { exportTrainNumber: null, }, ); + await this.wagonHistory.record( + manager, + consist.map((w) => ({ + wagonId: w.id, + wagonNumber: w.wagonNumber, + type: WagonEventType.TrainDisbanded, + trainId: train.id, + fromYardId: w.currentYardId ?? null, + fromValue: w.sequenceNumber, + reason: `Train ${train.code} disbanded`, + metadata: { status: { from: w.status, to: WagonStatus.Available } }, + })), + ); await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); await manager.getRepository(Train).remove(train); }); @@ -1365,6 +1514,25 @@ export class TrainBuilderService { ), ); + // COUPLED rows are written by attachWagons (build + assign); the detach + // side is logged here, where the reason and the live schedule are known. + await this.wagonHistory.record( + manager, + changes + .filter((c) => c.action === 'REMOVE') + .map((c) => ({ + wagonId: c.wagonId, + wagonNumber: c.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + occurredAt: now, + actorUserId: userId, + trainId, + trainScheduleId: schedule?.id ?? null, + fromYardId: yardId, + reason: reason?.trim() || null, + })), + ); + if (!schedule) return null; await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount }); @@ -1476,6 +1644,7 @@ export class TrainBuilderService { train: Train, wagonIds: string[], startCount: number, + userId: string | null = null, ): Promise { const uniqueIds = [...new Set(wagonIds)]; const wagonRepo = manager.getRepository(Wagon); @@ -1505,6 +1674,7 @@ export class TrainBuilderService { await this.assertConsistLengthWithinLimit(manager, train, toAttach); let sequence = startCount; + const events: WagonEventInput[] = []; for (const wagon of toAttach) { sequence += 1; await wagonRepo.update(wagon.id, { @@ -1516,7 +1686,23 @@ export class TrainBuilderService { importTrainNumber: train.importTrainNumber, exportTrainNumber: train.exportTrainNumber, }); + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId, + trainId: train.id, + fromYardId: wagon.currentYardId ?? null, + toValue: sequence, + metadata: { + trainCode: train.code, + status: { from: wagon.status, to: WagonStatus.Assigned }, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, + }, + }); } + await this.wagonHistory.record(manager, events); return toAttach; } diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts index be3810c0b..d035c53f8 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -1,25 +1,34 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsBoolean, + IsDateString, + IsEmail, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; const toBoolean = ({ value }: { value: unknown }) => { - if (typeof value === 'boolean') return value; - if (value === 'true') return true; - if (value === 'false') return false; + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; return value; }; export class CreateTransitAgentDto { - @ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' }) + @ApiProperty({ maxLength: 150, example: "Ahmed Bourhan" }) @IsString() @MaxLength(150) name!: string; - @ApiProperty({ example: '2026-01-01' }) + @ApiProperty({ example: "2026-01-01" }) @IsDateString() validFrom!: string; - @ApiProperty({ example: '2026-12-31' }) + @ApiProperty({ example: "2026-12-31" }) @IsDateString() validTo!: string; @@ -28,4 +37,33 @@ export class CreateTransitAgentDto { @Transform(toBoolean) @IsBoolean() isActive?: boolean; + + /** + * Becomes the IAM account's email and is where the activation link is sent. + * Optional: an agent may be created as a GL-assignable roster entry only, and + * invited later. Supplying it creates the portal account right away. + */ + @ApiPropertyOptional({ example: "a.bourhan@transit.dj" }) + @IsOptional() + @IsEmail() + @MaxLength(150) + email?: string; + + @ApiPropertyOptional({ + example: "+25377834567", + description: + "E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.", + }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + /** Login name. Defaults to the email, which is what the agent tries first. */ + @ApiPropertyOptional({ example: "a-bourhan" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; } diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts new file mode 100644 index 000000000..7b317c58c --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/invite-transit-agent.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsOptional, IsString, MaxLength } from "class-validator"; + +import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; + +/** + * Give an EXISTING roster-only transit agent a portal login. + * + * Email is required here even though it is optional on the agent itself: this + * endpoint's whole job is to send the activation link, and email is the only + * channel guaranteed to reach a Djibouti-registered officer. Omitting a field + * keeps whatever the agent already has. + */ +export class InviteTransitAgentDto { + @ApiProperty({ example: "a.bourhan@transit.dj" }) + @IsEmail() + @MaxLength(150) + email!: string; + + @ApiPropertyOptional({ + example: "+25377834567", + description: + "E.164. Djiboutian (+253 77…) and Ethiopian (+251 9…) mobiles also receive the activation link by SMS.", + }) + @IsOptional() + @IsString() + @MaxLength(30) + @IsValidPhone() + phoneNumber?: string; + + @ApiPropertyOptional({ example: "a-bourhan" }) + @IsOptional() + @IsString() + @MaxLength(100) + username?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts index 7e18a93da..08f28473c 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/update-transit-agent.dto.ts @@ -1,5 +1,5 @@ -import { PartialType } from '@nestjs/mapped-types'; +import { PartialType } from "@nestjs/mapped-types"; -import { CreateTransitAgentDto } from './create-transit-agent.dto'; +import { CreateTransitAgentDto } from "./create-transit-agent.dto"; export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {} diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts index 6d0ef9158..433b6587f 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -1,5 +1,5 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; /** * Djibouti transit officer GL Djibouti may assign against a shipment's @@ -7,18 +7,43 @@ import { Column, Entity, Index } from 'typeorm'; * validity window arrive without a code change; `isActive` is the manual * suspend/reactivate switch, independent of the validity window. */ -@Entity({ schema: 'freight', name: 'transit_agents' }) -@Index(['isActive']) +@Entity({ schema: "freight", name: "transit_agents" }) +@Index(["isActive"]) export class TransitAgent extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 150 }) + @Column({ name: "name", type: "varchar", length: 150 }) name!: string; - @Column({ name: 'valid_from', type: 'date' }) + @Column({ name: "valid_from", type: "date" }) validFrom!: string; - @Column({ name: 'valid_to', type: 'date' }) + @Column({ name: "valid_to", type: "date" }) validTo!: string; - @Column({ name: 'is_active', type: 'boolean', default: true }) + @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; + + /** + * The IAM account (`iam.users`, userType `individual`) that signs in to the + * portal as this agent. No FK: `iam` is a separate schema owned by the IAM + * service, and the rest of the codebase reaches it by query rather than by + * relation. + * + * NULL for every agent that exists only as a GL-assignable roster entry — + * which is all of them before this feature, and stays legal afterwards. An + * agent gains an account when staff invite it, so `userId !== null` IS the + * "has a portal login" predicate; nothing else needs to track it. + */ + @Column({ name: "user_id", type: "uuid", nullable: true }) + userId?: string | null; + + /** + * Mirrors the IAM account's email; the activation link is sent here. Nullable + * because a roster-only agent has never needed one — but an invite cannot be + * sent without it, so {@link TransitAgentsService.invite} requires it. + */ + @Column({ name: "email", type: "varchar", length: 150, nullable: true }) + email?: string | null; + + @Column({ name: "phone_number", type: "varchar", length: 30, nullable: true }) + phoneNumber?: string | null; } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts index 4f4b90c7b..d254f7982 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -10,36 +10,38 @@ import { Patch, Post, Query, -} from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { RuleEngineCreate, RuleEngineDelete, RuleEngineUpdate, RuleEngineView, -} from '../../common/rule-engine-guards'; +} from "../../common/rule-engine-guards"; -import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; -import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; -import { TransitAgentsService } from './transit-agents.service'; +import { BackofficeResetPasswordDto } from "../auth/dto/forgot-password.dto"; +import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; +import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; +import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; +import { TransitAgentsService } from "./transit-agents.service"; -@ApiTags('transit-agents') -@Controller('transit-agents') +@ApiTags("transit-agents") +@Controller("transit-agents") @ApiBearerAuth() export class TransitAgentsController { constructor(private readonly transitAgentsService: TransitAgentsService) {} @Get() - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'List transit agents' }) + @RuleEngineView("transit-agents") + @ApiOperation({ summary: "List transit agents" }) findAll(@Query() query: Record) { return this.transitAgentsService.findAll({ isActive: - query.isActive === 'all' + query.isActive === "all" ? undefined : query.isActive !== undefined - ? query.isActive === 'true' + ? query.isActive === "true" : undefined, page: query.page ? parseInt(query.page, 10) : undefined, pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined, @@ -49,39 +51,77 @@ export class TransitAgentsController { } /** Active + currently valid officers — the transit-assignee assignment dropdown. */ - @Get('assignable') - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' }) + @Get("assignable") + @RuleEngineView("transit-agents") + @ApiOperation({ + summary: "List transit agents assignable right now (active and in-window)", + }) findAssignable() { return this.transitAgentsService.findAssignable(); } - @Get(':id') - @RuleEngineView('transit-agents') - @ApiOperation({ summary: 'Get a transit agent by ID' }) - findOne(@Param('id', ParseUUIDPipe) id: string) { + @Get(":id") + @RuleEngineView("transit-agents") + @ApiOperation({ summary: "Get a transit agent by ID" }) + findOne(@Param("id", ParseUUIDPipe) id: string) { return this.transitAgentsService.findById(id); } @Post() - @RuleEngineCreate('transit-agents') - @ApiOperation({ summary: 'Create a transit agent' }) + @RuleEngineCreate("transit-agents") + @ApiOperation({ + summary: + "Create a transit agent; with an email, also creates its portal account and sends the activation link", + }) create(@Body() dto: CreateTransitAgentDto) { - return this.transitAgentsService.create(dto); + return this.transitAgentsService.createWithInvite(dto); } - @Patch(':id') - @RuleEngineUpdate('transit-agents') - @ApiOperation({ summary: 'Update a transit agent' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) { + /** + * The path for the roster entries already in production: they were created + * before transit agents had logins, so they get their account here rather + * than at create time. + */ + @Post(":id/invite") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ + summary: + "Create a portal account for an existing transit agent and send the activation link", + }) + invite( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: InviteTransitAgentDto, + ) { + return this.transitAgentsService.invite(id, dto); + } + + @Post(":id/resend-activation") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ + summary: "Resend a transit agent's activation / password-reset link", + }) + resendActivation( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: BackofficeResetPasswordDto, + ) { + return this.transitAgentsService.resendActivation(id, dto.channel); + } + + @Patch(":id") + @RuleEngineUpdate("transit-agents") + @ApiOperation({ summary: "Update a transit agent" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateTransitAgentDto, + ) { return this.transitAgentsService.update(id, dto); } - @Delete(':id') - @RuleEngineDelete('transit-agents') + @Delete(":id") + @RuleEngineDelete("transit-agents") @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Soft-delete a transit agent' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete a transit agent" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.transitAgentsService.remove(id); } } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts index 47e655e94..425971170 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.module.ts @@ -1,13 +1,24 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; -import { TransitAgent } from './entities/transit-agent.entity'; -import { TransitAgentsController } from './transit-agents.controller'; -import { TransitAgentsRepository } from './transit-agents.repository'; -import { TransitAgentsService } from './transit-agents.service'; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { FreightAuthModule } from "../auth/freight-auth.module"; +import { OtpModule } from "../otp/otp.module"; +import { TransitAgent } from "./entities/transit-agent.entity"; +import { TransitAgentsController } from "./transit-agents.controller"; +import { TransitAgentsRepository } from "./transit-agents.repository"; +import { TransitAgentsService } from "./transit-agents.service"; @Module({ - imports: [TypeOrmModule.forFeature([TransitAgent])], + imports: [ + // `User` is registered here so this module can create the IAM account that + // backs an invited transit agent, in the same transaction as the agent row. + TypeOrmModule.forFeature([TransitAgent, User]), + // CustomerResetService — activation links reuse the staff-triggered reset path. + FreightAuthModule, + OtpModule, + ], controllers: [TransitAgentsController], providers: [TransitAgentsRepository, TransitAgentsService], exports: [TransitAgentsRepository, TransitAgentsService], diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts index 4418ad938..5ae4191eb 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -1,9 +1,14 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EntityManager, + LessThanOrEqual, + MoreThanOrEqual, + Repository, +} from "typeorm"; -import { TransitAgent } from './entities/transit-agent.entity'; +import { TransitAgent } from "./entities/transit-agent.entity"; @Injectable() export class TransitAgentsRepository extends BaseRepository { @@ -22,7 +27,48 @@ export class TransitAgentsRepository extends BaseRepository { validFrom: LessThanOrEqual(today), validTo: MoreThanOrEqual(today), }, - order: { name: 'ASC' }, + order: { name: "ASC" }, }); } + + /** The transit agent signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + return this.repository.findOne({ where: { userId } }); + } + + /** + * Case-insensitive, matching the `lower(email)` unique index. `exceptId` lets + * an update re-save its own address without colliding with itself. + */ + async existsByEmail(email: string, exceptId?: string): Promise { + const qb = this.repository + .createQueryBuilder("ta") + .where("lower(ta.email) = lower(:email)", { email }); + if (exceptId) qb.andWhere("ta.id != :exceptId", { exceptId }); + return (await qb.getCount()) > 0; + } + + /** + * Insert inside a caller-supplied transaction, so the agent row and the IAM + * user it points at commit together — a row referencing a user that was + * rolled back (or vice versa) is an account nobody can sign in to. + */ + createInTransaction( + manager: EntityManager, + data: Partial, + ): Promise { + const repo = manager.getRepository(TransitAgent); + return repo.save(repo.create(data)); + } + + /** Attach an IAM account to an existing agent, inside the caller's transaction. */ + async linkAccountInTransaction( + manager: EntityManager, + id: string, + data: Pick, + ): Promise { + const repo = manager.getRepository(TransitAgent); + await repo.update(id, data); + return repo.findOneOrFail({ where: { id } }); + } } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts new file mode 100644 index 000000000..3aa5e6e02 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts @@ -0,0 +1,346 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; + +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { TransitAgentsService } from "./transit-agents.service"; + +/** + * The account half of a transit agent. The roster half (validity window, + * assignability) predates this and is untouched — what these lock is that + * adding a login did not make an account MANDATORY, since production is full of + * roster-only agents that must keep working. + */ +describe("TransitAgentsService accounts", () => { + const savedUser = { id: "user-1" }; + + let repo: { + existsByEmail: jest.Mock; + createInTransaction: jest.Mock; + linkAccountInTransaction: jest.Mock; + findById: jest.Mock; + findByUserId: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + let userRepository: { findOne: jest.Mock; update: jest.Mock }; + let customerResetService: { + sendResetLinkToUser: jest.Mock; + sendResetLinkToUserOnChannels: jest.Mock; + }; + let dataSource: { transaction: jest.Mock }; + let userRepoInTx: { create: jest.Mock; save: jest.Mock }; + let service: TransitAgentsService; + + const base = { + name: "Ahmed Bourhan", + validFrom: "2026-01-01", + validTo: "2026-12-31", + }; + + beforeEach(() => { + userRepoInTx = { + create: jest.fn((v) => v), + save: jest.fn().mockResolvedValue(savedUser), + }; + + repo = { + existsByEmail: jest.fn().mockResolvedValue(false), + createInTransaction: jest.fn(async (_m, data) => ({ + id: "ta-1", + ...data, + })), + linkAccountInTransaction: jest.fn(async (_m, id, data) => ({ + id, + ...base, + isActive: true, + ...data, + })), + findById: jest.fn(), + findByUserId: jest.fn(), + create: jest.fn(async (data) => ({ id: "ta-1", ...data })), + // `BaseRepository.update` re-reads the row via `findById`, so the result + // carries columns the caller never passed — `userId` above all, which is + // what decides whether IAM gets synced. + update: jest.fn(async (id, data) => ({ + ...(await repo.findById(id)), + id, + ...data, + })), + }; + userRepository = { + findOne: jest.fn().mockResolvedValue(null), + update: jest.fn(), + }; + customerResetService = { + sendResetLinkToUser: jest + .fn() + .mockResolvedValue({ + maskedTarget: "a**@transit.dj", + channel: ResetChannel.Email, + }), + sendResetLinkToUserOnChannels: jest + .fn() + .mockResolvedValue([ + { maskedTarget: "a**@transit.dj", channel: ResetChannel.Email }, + ]), + }; + dataSource = { + transaction: jest.fn(async (cb) => + cb({ getRepository: () => userRepoInTx } as never), + ), + }; + + service = new TransitAgentsService( + repo as never, + userRepository as never, + customerResetService as never, + dataSource as never, + ); + }); + + describe("create", () => { + it("creates a roster-only agent with no account when no email is given", async () => { + const { agent, activationSentTo } = await service.createWithInvite(base); + + expect(dataSource.transaction).not.toHaveBeenCalled(); + expect( + customerResetService.sendResetLinkToUserOnChannels, + ).not.toHaveBeenCalled(); + expect(agent.hasAccount).toBe(false); + expect(activationSentTo).toBeNull(); + }); + + it("creates the IAM account with no password set when an email is given", async () => { + await service.createWithInvite({ + ...base, + email: "A.Bourhan@Transit.DJ", + }); + + expect(userRepoInTx.save).toHaveBeenCalledWith( + expect.objectContaining({ + email: "a.bourhan@transit.dj", + username: "a.bourhan@transit.dj", + userType: EUserType.INDIVIDUAL, + hasSetPassword: false, + status: EUserStatus.ACCEPTED, + }), + ); + }); + + it("sends the activation link only after the transaction commits", async () => { + const order: string[] = []; + dataSource.transaction.mockImplementation( + async (cb: (m: unknown) => unknown) => { + const result = await cb({ getRepository: () => userRepoInTx }); + order.push("commit"); + return result; + }, + ); + customerResetService.sendResetLinkToUserOnChannels.mockImplementation( + async () => { + order.push("send"); + return [ + { maskedTarget: "a**@transit.dj", channel: ResetChannel.Email }, + ]; + }, + ); + + await service.createWithInvite({ ...base, email: "a@transit.dj" }); + + expect(order).toEqual(["commit", "send"]); + }); + }); + + describe("invite", () => { + it("attaches an account to an existing roster-only agent and sends the link", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + const { agent, activationSentTo } = await service.invite("ta-1", { + email: "a@transit.dj", + }); + + expect(repo.linkAccountInTransaction).toHaveBeenCalledWith( + expect.anything(), + "ta-1", + expect.objectContaining({ userId: "user-1", email: "a@transit.dj" }), + ); + expect(agent.hasAccount).toBe(true); + expect(activationSentTo).toBe("a**@transit.dj"); + }); + + it("refuses to mint a second account for an agent that already has one", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-9", + }); + + await expect( + service.invite("ta-1", { email: "a@transit.dj" }), + ).rejects.toThrow(ConflictException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses credentials that already belong to another account", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + userRepository.findOne.mockResolvedValue({ id: "someone-else" }); + + await expect( + service.invite("ta-1", { email: "a@transit.dj" }), + ).rejects.toThrow(ConflictException); + }); + + it("texts the link as well when the number is domestic", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.invite("ta-1", { + email: "a@transit.dj", + phoneNumber: "+251911223344", + }); + + expect( + customerResetService.sendResetLinkToUserOnChannels, + ).toHaveBeenCalledWith( + "user-1", + [ResetChannel.Email, ResetChannel.Phone], + expect.objectContaining({ allowWithoutCredential: true }), + ); + }); + + it("emails only when the number is foreign — the SMS gateway is domestic-only", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.invite("ta-1", { + email: "a@transit.dj", + phoneNumber: "+33612345678", + }); + + expect( + customerResetService.sendResetLinkToUserOnChannels, + ).toHaveBeenCalledWith("user-1", [ResetChannel.Email], expect.anything()); + }); + }); + + describe("update", () => { + it("mirrors an edited email onto the linked IAM account", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-1", + }); + + await service.update("ta-1", { email: "New@Transit.DJ" }); + + expect(repo.update).toHaveBeenCalledWith( + "ta-1", + expect.objectContaining({ email: "new@transit.dj" }), + ); + expect(userRepository.update).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ email: "new@transit.dj" }), + ); + }); + + it("never writes username — it names an IAM account, not a column on this table", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.update("ta-1", { username: "nope" } as never); + + expect(repo.update).toHaveBeenCalledWith( + "ta-1", + expect.not.objectContaining({ username: expect.anything() }), + ); + }); + + it("leaves IAM alone for a roster-only agent", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await service.update("ta-1", { email: "a@transit.dj" }); + + expect(userRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe("resendActivation", () => { + it("refuses for an agent that has no account yet", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: null, + }); + + await expect( + service.resendActivation("ta-1", ResetChannel.Email), + ).rejects.toThrow(BadRequestException); + }); + + it("refuses an SMS resend to a foreign number", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-1", + phoneNumber: "+33612345678", + }); + + await expect( + service.resendActivation("ta-1", ResetChannel.Phone), + ).rejects.toThrow(BadRequestException); + }); + + it("reuses the existing account rather than minting a new one", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + isActive: true, + userId: "user-1", + email: "a@transit.dj", + }); + + await service.resendActivation("ta-1", ResetChannel.Email); + + expect(customerResetService.sendResetLinkToUser).toHaveBeenCalledWith( + "user-1", + ResetChannel.Email, + expect.objectContaining({ allowWithoutCredential: true }), + ); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts index ec9c24e9d..b54f1fddb 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -1,17 +1,49 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { FindOptionsOrder } from 'typeorm'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { + EUserStatus, + EUserType, +} from "@tria-plc/api-common/utils/enums/user.enum"; +// Subpath import (not the package root) so ts-jest can resolve it when this +// file lands in a spec's compile graph — same reason as backoffice.service.ts. +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { + DataSource, + EntityManager, + FindOptionsOrder, + Repository, +} from "typeorm"; -import { CreateTransitAgentDto } from './dto/create-transit-agent.dto'; -import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto'; -import { TransitAgent } from './entities/transit-agent.entity'; -import { TransitAgentsRepository } from './transit-agents.repository'; +import { CustomerResetService } from "../auth/customer-reset.service"; +import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { isDomesticPhone } from "../otp/otp.service"; +import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; +import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; +import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; +import { TransitAgent } from "./entities/transit-agent.entity"; +import { TransitAgentsRepository } from "./transit-agents.repository"; -export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED'; +export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED"; export type TransitAgentView = TransitAgent & { validityStatus: TransitAgentValidityStatus; + /** True once an IAM account backs this agent — i.e. it can sign in. */ + hasAccount: boolean; }; +export interface InvitedTransitAgent { + agent: TransitAgentView; + /** Masked destination of the activation link, or null if none was sent. */ + activationSentTo: string | null; + activationChannel: ResetChannel | null; +} + type TransitAgentListFilter = { isActive?: boolean; page?: number; @@ -25,20 +57,34 @@ function todayISODate(): string { return new Date().toISOString().slice(0, 10); } -function validityStatus(agent: Pick): TransitAgentValidityStatus { +function validityStatus( + agent: Pick, +): TransitAgentValidityStatus { const today = todayISODate(); - if (today < agent.validFrom) return 'NOT_STARTED'; - if (today > agent.validTo) return 'EXPIRED'; - return 'VALID'; + if (today < agent.validFrom) return "NOT_STARTED"; + if (today > agent.validTo) return "EXPIRED"; + return "VALID"; } function withValidityStatus(agent: TransitAgent): TransitAgentView { - return { ...agent, validityStatus: validityStatus(agent) }; + return { + ...agent, + validityStatus: validityStatus(agent), + hasAccount: Boolean(agent.userId), + }; } @Injectable() export class TransitAgentsService { - constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {} + private readonly logger = new Logger(TransitAgentsService.name); + + constructor( + private readonly transitAgentsRepository: TransitAgentsRepository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly customerResetService: CustomerResetService, + private readonly dataSource: DataSource, + ) {} async findAll(filter: TransitAgentListFilter = {}): Promise<{ data: TransitAgentView[]; @@ -46,10 +92,13 @@ export class TransitAgentsService { }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 500; - const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '') + const sortBy = ["name", "validFrom", "validTo", "isActive"].includes( + filter.sortBy ?? "", + ) ? (filter.sortBy as keyof TransitAgent) - : 'name'; - const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + : "name"; + const sortOrder = + filter.sortOrder?.toUpperCase() === "DESC" ? "DESC" : "ASC"; const [data, total] = await this.transitAgentsRepository.findAndCount({ where: filter.isActive === undefined ? {} : { isActive: filter.isActive }, @@ -86,12 +135,14 @@ export class TransitAgentsService { async getAssignable(id: string): Promise { const agent = await this.transitAgentsRepository.findById(id); if (!agent) { - throw new BadRequestException('Selected transit officer was not found.'); + throw new BadRequestException("Selected transit officer was not found."); } if (!agent.isActive) { - throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`); + throw new BadRequestException( + `${agent.name} is suspended — pick another transit officer.`, + ); } - if (validityStatus(agent) !== 'VALID') { + if (validityStatus(agent) !== "VALID") { throw new BadRequestException( `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, ); @@ -99,38 +150,364 @@ export class TransitAgentsService { return agent; } - async create(dto: CreateTransitAgentDto): Promise { - if (dto.validTo < dto.validFrom) { - throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + /** + * Create an IAM account for a transit agent, inside the caller's transaction. + * + * Follows `ShippingLineCompaniesService.register` — same entities, same shape + * — including its one deliberate difference from employee creation: no + * `UserCredential` row is written and `hasSetPassword` stays false, so the + * agent must come through the activation link. Staff never handle a password. + */ + private async createIamAccount( + manager: EntityManager, + args: { + name: string; + email: string; + username: string; + phoneNumber?: string; + }, + ): Promise { + const userRepo = manager.getRepository(User); + const user = await userRepo.save( + userRepo.create({ + email: args.email, + username: args.username, + phoneNumber: args.phoneNumber, + name: { en: args.name }, + userType: EUserType.INDIVIDUAL, + isActive: true, + // No credential row: the account has no password until the activation + // link is used. `hasSetPassword` must stay false or the portal treats + // the account as ready to sign in with a password that does not exist. + hasSetPassword: false, + status: EUserStatus.ACCEPTED, + }), + ); + return user.id as string; + } + + /** + * Normalize and validate the account fields shared by create and invite, and + * refuse credentials that already belong to somebody. + */ + private async prepareAccountFields( + dto: { email: string; phoneNumber?: string; username?: string }, + exceptAgentId?: string, + ) { + const email = dto.email.trim().toLowerCase(); + const username = (dto.username?.trim() || email).toLowerCase(); + const phoneNumber = dto.phoneNumber?.trim() || undefined; + + if ( + await this.transitAgentsRepository.existsByEmail(email, exceptAgentId) + ) { + throw new ConflictException( + `A transit agent with email ${email} already exists`, + ); } - const agent = await this.transitAgentsRepository.create({ + + // An existing IAM account means these credentials already belong to a + // customer, a shipping line or an employee. Reusing it would let one login + // resolve to two different account kinds, so this is refused rather than + // merged. + const existingUser = await this.userRepository.findOne({ + where: [{ email }, { username }], + select: { id: true }, + }); + if (existingUser) { + throw new ConflictException("email_or_username_already_in_use"); + } + + return { email, username, phoneNumber }; + } + + /** + * Create a transit agent. + * + * With no `email` this is the pre-existing behaviour: a GL-assignable roster + * entry with no login, which is what production is full of. With an `email` + * the IAM account and the agent row are created in one transaction and the + * activation link goes out. + */ + async create(dto: CreateTransitAgentDto): Promise { + return (await this.createWithInvite(dto)).agent; + } + + /** {@link create}, also reporting where the activation link went. */ + async createWithInvite( + dto: CreateTransitAgentDto, + ): Promise { + if (dto.validTo < dto.validFrom) { + throw new BadRequestException( + "Valid-to date must be on or after valid-from date.", + ); + } + + const base = { name: dto.name.trim(), validFrom: dto.validFrom, validTo: dto.validTo, isActive: dto.isActive ?? true, + }; + + if (!dto.email) { + // Roster-only agent — no account, nothing to send. + const agent = await this.transitAgentsRepository.create(base); + return { + agent: withValidityStatus(agent), + activationSentTo: null, + activationChannel: null, + }; + } + + const { email, username, phoneNumber } = await this.prepareAccountFields({ + email: dto.email, + phoneNumber: dto.phoneNumber, + username: dto.username, }); - return withValidityStatus(agent); + + const agent = await this.dataSource.transaction(async (manager) => { + const userId = await this.createIamAccount(manager, { + name: base.name, + email, + username, + phoneNumber, + }); + return this.transitAgentsRepository.createInTransaction(manager, { + ...base, + userId, + email, + phoneNumber: phoneNumber ?? null, + }); + }); + + // Outside the transaction on purpose: a delivery failure must not roll back + // a registered agent. The link is resendable, and the account is already + // valid without it. + const activation = await this.sendActivationLink(agent); + return { + agent: withValidityStatus(agent), + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; } - async update(id: string, dto: UpdateTransitAgentDto): Promise { + /** + * Give an EXISTING agent a portal login — the path for the roster entries + * already in production. Creates the IAM account, attaches it, and sends the + * activation link. + */ + async invite( + id: string, + dto: InviteTransitAgentDto, + ): Promise { + const current = await this.transitAgentsRepository.findById(id); + if (!current) { + throw new NotFoundException(`Transit agent ${id} not found`); + } + if (current.userId) { + // Already has an account — resending is `resendActivation`, which reuses + // the existing user instead of minting a second one for the same person. + throw new ConflictException( + "This transit agent already has a portal account — resend the activation link instead.", + ); + } + + const { email, username, phoneNumber } = await this.prepareAccountFields( + dto, + id, + ); + + const agent = await this.dataSource.transaction(async (manager) => { + const userId = await this.createIamAccount(manager, { + name: current.name, + email, + username, + phoneNumber, + }); + return this.transitAgentsRepository.linkAccountInTransaction( + manager, + id, + { + userId, + email, + phoneNumber: phoneNumber ?? null, + }, + ); + }); + + const activation = await this.sendActivationLink(agent); + return { + agent: withValidityStatus(agent), + activationSentTo: activation?.maskedTarget ?? null, + activationChannel: activation?.channel ?? null, + }; + } + + /** + * Send the activation link. + * + * Email always goes out — it is the only channel guaranteed to reach a + * foreign-registered officer. SMS is sent in addition when the number is + * domestic, since the gateway silently drops anything else. Both carry the + * SAME single-use ticket: minting retires earlier tickets, so two mints would + * kill the email link the moment the SMS went out. + * + * Reports the email send, as that is the one that is always attempted. + */ + async sendActivationLink(agent: TransitAgent) { + if (!agent.userId) return null; + + const scope = `transit agent ${agent.id}`; + const channels = [ResetChannel.Email]; + if (agent.phoneNumber && isDomesticPhone(agent.phoneNumber)) { + channels.push(ResetChannel.Phone); + } + + const sent = await this.customerResetService.sendResetLinkToUserOnChannels( + agent.userId, + channels, + { scope, allowWithoutCredential: true }, + ); + const emailed = sent.find((s) => s.channel === ResetChannel.Email) ?? null; + + if (!emailed) { + this.logger.error( + `Activation email not sent for transit agent ${agent.id} — no reachable address`, + ); + } + if ( + channels.includes(ResetChannel.Phone) && + !sent.some((s) => s.channel === ResetChannel.Phone) + ) { + this.logger.warn(`Activation SMS not sent for transit agent ${agent.id}`); + } + + return emailed; + } + + async resendActivation(id: string, channel: ResetChannel) { + const agent = await this.transitAgentsRepository.findById(id); + if (!agent) { + throw new NotFoundException("Transit agent not found"); + } + if (!agent.userId) { + throw new BadRequestException( + "This transit agent has no portal account yet — invite them first.", + ); + } + + if ( + channel === ResetChannel.Phone && + (!agent.phoneNumber || !isDomesticPhone(agent.phoneNumber)) + ) { + throw new BadRequestException( + "This transit agent has no domestic phone number — the SMS gateway cannot reach it", + ); + } + + const sent = await this.customerResetService.sendResetLinkToUser( + agent.userId, + channel, + { + scope: `transit agent ${agent.id}`, + allowWithoutCredential: true, + }, + ); + + if (!sent) { + throw new NotFoundException( + `No active account with ${ + channel === ResetChannel.Email ? "an email address" : "a phone number" + } for this transit agent`, + ); + } + + return sent; + } + + /** The transit agent signed in as `userId`, or null for any other account. */ + findByUserId(userId: string): Promise { + return this.transitAgentsRepository.findByUserId(userId); + } + + async update( + id: string, + dto: UpdateTransitAgentDto, + ): Promise { const current = await this.findById(id); const nextValidFrom = dto.validFrom ?? current.validFrom; const nextValidTo = dto.validTo ?? current.validTo; if (nextValidTo < nextValidFrom) { - throw new BadRequestException('Valid-to date must be on or after valid-from date.'); + throw new BadRequestException( + "Valid-to date must be on or after valid-from date.", + ); + } + + // `username` only ever names an IAM account, and it is chosen once at + // account creation. Accepting it here (PartialType inherits it from the + // create DTO) would write a column that does not exist on this table. + const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto; + + const contact: Partial = {}; + if (email !== undefined) { + const normalized = email.trim().toLowerCase(); + if (await this.transitAgentsRepository.existsByEmail(normalized, id)) { + throw new ConflictException( + `A transit agent with email ${normalized} already exists`, + ); + } + contact.email = normalized; + } + if (phoneNumber !== undefined) { + contact.phoneNumber = phoneNumber.trim() || null; } const updated = await this.transitAgentsRepository.update(id, { - ...dto, + ...rest, + ...contact, ...(dto.name ? { name: dto.name.trim() } : {}), }); if (!updated) { throw new NotFoundException(`Transit agent ${id} not found`); } + + // Keep the IAM account in step. Without this, an agent whose address was + // corrected here would still receive its activation link at the old one — + // the reset service reads the address off `iam.users`, not off this row. + if ( + updated.userId && + (contact.email !== undefined || contact.phoneNumber !== undefined) + ) { + await this.syncIamContact(updated); + } + return withValidityStatus(updated); } + /** + * Mirror an edited email/phone onto the linked IAM account. + * + * Best-effort: a failure here must not fail the agent edit that already + * committed, but it does mean the two are out of step, so it is logged loudly + * rather than swallowed. Re-running the edit retries it. + */ + private async syncIamContact(agent: TransitAgent): Promise { + if (!agent.userId) return; + try { + await this.userRepository.update(agent.userId, { + ...(agent.email ? { email: agent.email } : {}), + phoneNumber: agent.phoneNumber ?? undefined, + }); + } catch (error) { + this.logger.error( + `Transit agent ${agent.id} contact updated but IAM user ${agent.userId} was not — ` + + `activation links will still go to the old address: ${String(error)}`, + ); + } + } + async remove(id: string): Promise { await this.findById(id); await this.transitAgentsRepository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts new file mode 100644 index 000000000..a21f75dd9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/create-transit-assignment.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +export class CreateTransitAssignmentDto { + @ApiProperty({ format: "uuid" }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ format: "uuid" }) + @IsUUID() + transitAgentId!: string; + + @ApiPropertyOptional({ + enum: TransitAssignmentStatus, + default: TransitAssignmentStatus.NotStarted, + description: + "Assignments normally start NOT_STARTED; pass one only to record work already under way.", + }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts new file mode 100644 index 000000000..4c7089780 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/my-assignments-query.dto.ts @@ -0,0 +1,43 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +/** Filters for the transit agent's own booking list. */ +export class MyAssignmentsQueryDto { + /** Free text over the booking reference and the customer's company name. */ + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: TransitAssignmentStatus }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ + example: "DISPATCHED", + description: "The booking's scheduling state.", + }) + @IsOptional() + @IsString() + schedulingStatus?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + // Bounded so a hand-edited query string cannot ask for the whole table. + @Max(100) + pageSize?: number; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts new file mode 100644 index 000000000..f6e91cd31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/submit-transit-assignment.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsOptional, IsString, MaxLength } from "class-validator"; + +/** The portal's Save / Finish action on the agent's own assignment. */ +export class SubmitTransitAssignmentDto { + @ApiProperty({ + description: + "true finishes the assignment, which also locks its documents. false saves progress and leaves it open.", + }) + // Arrives as a string when posted as multipart alongside files. + @Transform(({ value }) => + value === "true" ? true : value === "false" ? false : value, + ) + @IsBoolean() + finish!: boolean; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts new file mode 100644 index 000000000..166306f2a --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/transit-assignment-query.dto.ts @@ -0,0 +1,36 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +export class TransitAssignmentQueryDto { + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + bookingId?: string; + + @ApiPropertyOptional({ format: "uuid" }) + @IsOptional() + @IsUUID() + transitAgentId?: string; + + @ApiPropertyOptional({ enum: TransitAssignmentStatus }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + pageSize?: number; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts new file mode 100644 index 000000000..2dda6b34e --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/dto/update-transit-assignment.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEnum, IsOptional, IsString, MaxLength } from "class-validator"; + +import { TransitAssignmentStatus } from "../entities/transit-assignment.entity"; + +/** + * `bookingId` and `transitAgentId` are absent on purpose: repointing an + * assignment at a different booking or agent would silently reattribute the + * work and the documents already filed under it. Delete and re-create instead. + */ +export class UpdateTransitAssignmentDto { + @ApiPropertyOptional({ enum: TransitAssignmentStatus }) + @IsOptional() + @IsEnum(TransitAssignmentStatus) + status?: TransitAssignmentStatus; + + @ApiPropertyOptional({ maxLength: 2000 }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts new file mode 100644 index 000000000..dd7f07ac7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/entities/transit-assignment.entity.ts @@ -0,0 +1,79 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; + +import { Booking } from "../../bookings/entities/booking.entity"; +import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity"; + +/** Where the agent's work on this booking currently stands. */ +export enum TransitAssignmentStatus { + NotStarted = "NOT_STARTED", + InProgress = "IN_PROGRESS", + Finished = "FINISHED", +} + +/** + * One transit agent's work on one booking. An agent handles many bookings, so + * this is the join between the two, carrying the work's own state: when it + * started, when it finished, and the documents produced along the way. + * + * Deliberately separate from the transit-assignee handshake on the booking + * (`/bookings/:id/clearance/transit-assignee/...`), which is a pre-declaration + * agreement between GL Ethiopia and GL Djibouti about WHO will handle customs. + * Nothing here reads or writes that flow. + * + * There is no stored duration. "Time after the train arrives" is + * `finishedAt − booking.arrivedAt`; both halves already exist, and storing the + * difference would be a third source of truth that goes stale the moment either + * timestamp is corrected. It is computed on read — see + * `TransitAssignmentsService.toView`. + * + * Documents live in `freight.files` under + * {@link TRANSIT_ASSIGNMENT_FILE_RESOURCE}, which already carries the MinIO + * object, the upload time, the uploader and the supersede history. + */ +@Entity({ schema: "freight", name: "transit_assignments" }) +@Index(["bookingId"]) +@Index(["transitAgentId", "status"]) +export class TransitAssignment extends BaseEntity { + @Column({ name: "booking_id", type: "uuid" }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: "booking_id" }) + booking?: Booking; + + @Column({ name: "transit_agent_id", type: "uuid" }) + transitAgentId!: string; + + @ManyToOne(() => TransitAgent) + @JoinColumn({ name: "transit_agent_id" }) + transitAgent?: TransitAgent; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: TransitAssignmentStatus.NotStarted, + }) + status!: TransitAssignmentStatus; + + /** Stamped on the first move to IN_PROGRESS; never overwritten afterwards. */ + @Column({ name: "started_at", type: "timestamptz", nullable: true }) + startedAt?: Date | null; + + /** Stamped on the move to FINISHED. Cleared if the work is reopened. */ + @Column({ name: "finished_at", type: "timestamptz", nullable: true }) + finishedAt?: Date | null; + + @Column({ name: "assigned_by_user_id", type: "uuid", nullable: true }) + assignedByUserId?: string | null; + + @Column({ name: "assigned_at", type: "timestamptz", default: () => "now()" }) + assignedAt!: Date; + + @Column({ name: "note", type: "text", nullable: true }) + note?: string | null; +} + +/** `files.resource` value for documents attached to a transit assignment. */ +export const TRANSIT_ASSIGNMENT_FILE_RESOURCE = "transit_assignments"; diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts new file mode 100644 index 000000000..76dd0eb9c --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.controller.ts @@ -0,0 +1,255 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UploadedFiles, + UseInterceptors, +} from "@nestjs/common"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { + ApiBearerAuth, + ApiConsumes, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; + +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff, PortalCustomer } from "../../common/booking-guards"; +import { documentUploadMulterOptions } from "../../common/document-upload.options"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; +import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto"; +import { SubmitTransitAssignmentDto } from "./dto/submit-transit-assignment.dto"; +import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto"; +import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto"; +import { TransitAssignmentsService } from "./transit-assignments.service"; + +/** + * Transit assignments — one transit agent's work on one booking. + * + * Distinct from the transit-assignee handshake under + * `/bookings/:id/clearance/transit-assignee/...`, which decides WHO will handle + * a shipment's customs. This is the work record that follows: status, timings + * and documents. + */ +@ApiTags("transit-assignments") +@Controller("transit-assignments") +@ApiBearerAuth() +export class TransitAssignmentsController { + constructor( + private readonly transitAssignmentsService: TransitAssignmentsService, + ) {} + + // ── Portal — the signed-in transit agent's own work ─────────────────────── + // Declared first so the literal `my` segment is matched before `:id`. + // Every route resolves the agent from the session; none accepts an agent id. + + @Get("my/stats") + @PortalCustomer() + @ApiOperation({ + summary: "Dashboard figures for the signed-in transit agent's own work", + }) + myStats(@CurrentUser() user: TCurrentUser) { + return this.transitAssignmentsService.myStats(user.id); + } + + @Get("my") + @PortalCustomer() + @ApiOperation({ + summary: + "The signed-in transit agent's assigned bookings (paginated, filterable)", + }) + findMine( + @CurrentUser() user: TCurrentUser, + @Query() query: MyAssignmentsQueryDto, + ) { + return this.transitAssignmentsService.findMine(user.id, query); + } + + @Get("my/:id") + @PortalCustomer() + @ApiOperation({ summary: "One of my assignments, with its documents" }) + findMineById( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + ) { + return this.transitAssignmentsService.findMineById(user.id, id); + } + + @Post("my/:id/files") + @PortalCustomer() + @ApiConsumes("multipart/form-data") + @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) + @ApiOperation({ + summary: + "Upload documents to my assignment. Allowed only while the booking is DISPATCHED and the assignment is not finished.", + }) + uploadMyFiles( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + // One `titles` part per file, in the same order. A single-file upload posts + // one part, which multipart parsing hands back as a bare string rather than + // an array — normalised here so the service always sees a positional list. + @Body("titles") titles?: string | string[], + ) { + return this.transitAssignmentsService.uploadMyFiles( + user.id, + id, + files, + { userId: user.id, name: user.name?.en ?? undefined }, + titles === undefined ? undefined : ([] as string[]).concat(titles), + ); + } + + @Delete("my/:id/files/:fileId") + @PortalCustomer() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove a document from my assignment" }) + removeMyFile( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + ) { + return this.transitAssignmentsService.removeMyFile(user.id, id, fileId); + } + + @Post("my/:id/submit") + @PortalCustomer() + @ApiOperation({ + summary: + "Save progress, or finish the assignment (which locks its documents)", + }) + submitMine( + @CurrentUser() user: TCurrentUser, + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: SubmitTransitAssignmentDto, + ) { + return this.transitAssignmentsService.submitMine(user.id, id, dto); + } + + // ── Backoffice ──────────────────────────────────────────────────────────── + + @Get() + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ + summary: + "List transit assignments (paginated, filterable by booking / agent / status)", + }) + findAll(@Query() query: TransitAssignmentQueryDto) { + return this.transitAssignmentsService.findAll(query); + } + + /** + * Declared before `:id` — Nest matches routes in order, so a literal segment + * registered after a parameter would be swallowed by it. + */ + @Get("by-agent/:transitAgentId") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ summary: "Every assignment handed to one transit agent" }) + findByTransitAgent( + @Param("transitAgentId", ParseUUIDPipe) transitAgentId: string, + ) { + return this.transitAssignmentsService.findByTransitAgent(transitAgentId); + } + + @Get("by-booking/:bookingId") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ summary: "Every transit agent assigned to one booking" }) + findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { + return this.transitAssignmentsService.findByBooking(bookingId); + } + + @Get(":id") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ + summary: + "One assignment, with its attached documents and computed duration", + }) + findOne(@Param("id", ParseUUIDPipe) id: string) { + return this.transitAssignmentsService.findById(id); + } + + @Post() + @BookingStaff(FREIGHT_PERMS.transitAssignments.create) + @ApiOperation({ summary: "Assign a transit agent to a booking" }) + create( + @Body() dto: CreateTransitAssignmentDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitAssignmentsService.create(dto, user?.id); + } + + @Patch(":id") + @BookingStaff(FREIGHT_PERMS.transitAssignments.update) + @ApiOperation({ + summary: + "Update status or note — status changes stamp the start/finish clocks", + }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateTransitAssignmentDto, + ) { + return this.transitAssignmentsService.update(id, dto); + } + + @Delete(":id") + @BookingStaff(FREIGHT_PERMS.transitAssignments.delete) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Soft-delete an assignment" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.transitAssignmentsService.remove(id); + } + + // ── Documents ───────────────────────────────────────────────────────────── + + @Get(":id/files") + @BookingStaff(FREIGHT_PERMS.transitAssignments.view) + @ApiOperation({ summary: "An assignment's uploaded documents" }) + listFiles(@Param("id", ParseUUIDPipe) id: string) { + return this.transitAssignmentsService.listFiles(id); + } + + @Post(":id/files") + @BookingStaff(FREIGHT_PERMS.transitAssignments.update) + @ApiConsumes("multipart/form-data") + @UseInterceptors(AnyFilesInterceptor(documentUploadMulterOptions)) + @ApiOperation({ + summary: + "Upload one or more documents; re-uploading adds a version, it does not overwrite", + }) + uploadFiles( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + @Body("titles") titles?: string | string[], + ) { + return this.transitAssignmentsService.uploadFiles( + id, + files, + { userId: user?.id, name: user?.name?.en ?? undefined }, + titles === undefined ? undefined : ([] as string[]).concat(titles), + ); + } + + @Delete(":id/files/:fileId") + @BookingStaff(FREIGHT_PERMS.transitAssignments.update) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: "Remove one document from an assignment" }) + removeFile( + @Param("id", ParseUUIDPipe) id: string, + @Param("fileId", ParseUUIDPipe) fileId: string, + ) { + return this.transitAssignmentsService.removeFile(id, fileId); + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts new file mode 100644 index 000000000..44862e2ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -0,0 +1,35 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; +import { FilesModule } from "../files/files.module"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { TransitAgentsModule } from "../transit-agents/transit-agents.module"; +import { TransitAssignment } from "./entities/transit-assignment.entity"; +import { TransitAssignmentsController } from "./transit-assignments.controller"; +import { TransitAssignmentsRepository } from "./transit-assignments.repository"; +import { TransitAssignmentsService } from "./transit-assignments.service"; + +@Module({ + imports: [ + // `Booking` is registered as an ENTITY rather than importing BookingsModule: + // this module only confirms a booking id exists, and that module would drag + // its whole graph (billing, contracts, scheduling, first/last mile) along. + // Milestones and train schedules are read for the agent's dashboard + // timings (declaration stamps, departure/arrival fallbacks) — entities + // only, for the same reason as Booking. + TypeOrmModule.forFeature([ + TransitAssignment, + Booking, + ClearanceMilestone, + TrainSchedule, + ]), + FilesModule, + TransitAgentsModule, + ], + controllers: [TransitAssignmentsController], + providers: [TransitAssignmentsService, TransitAssignmentsRepository], + exports: [TransitAssignmentsService, TransitAssignmentsRepository], +}) +export class TransitAssignmentsModule {} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts new file mode 100644 index 000000000..9030d58f7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.repository.ts @@ -0,0 +1,139 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { + TransitAssignment, + TransitAssignmentStatus, +} from "./entities/transit-assignment.entity"; + +export interface TransitAssignmentFilter { + bookingId?: string; + transitAgentId?: string; + status?: TransitAssignmentStatus; + /** The booking's scheduling state (DISPATCHED / SCHEDULED / …). */ + schedulingStatus?: string; + /** Free text over the booking reference and the customer's company name. */ + search?: string; +} + +@Injectable() +export class TransitAssignmentsRepository extends BaseRepository { + constructor( + @InjectRepository(TransitAssignment) + private readonly assignmentsRepo: Repository, + ) { + super(assignmentsRepo); + } + + /** + * The booking is joined rather than lazily loaded because every read needs + * its `arrivedAt` — that is the other half of the computed + * "time after the train arrives", so a list without it would be N+1 queries + * or a column of nulls. The customer's company rides along for the same + * reason: the agent's list is read by reference AND by whose cargo it is. + */ + private baseQuery() { + return this.assignmentsRepo + .createQueryBuilder("ta") + .leftJoinAndSelect("ta.booking", "booking") + .leftJoinAndSelect("booking.company", "company") + .leftJoinAndSelect("ta.transitAgent", "agent") + .where("ta.deletedAt IS NULL"); + } + + /** Shared filter application, so a list and its count can never diverge. */ + private applyFilters( + qb: ReturnType, + filter: TransitAssignmentFilter, + ) { + if (filter.bookingId) { + qb.andWhere("ta.bookingId = :bookingId", { bookingId: filter.bookingId }); + } + if (filter.transitAgentId) { + qb.andWhere("ta.transitAgentId = :transitAgentId", { + transitAgentId: filter.transitAgentId, + }); + } + if (filter.status) { + qb.andWhere("ta.status = :status", { status: filter.status }); + } + if (filter.schedulingStatus) { + qb.andWhere("booking.schedulingStatus = :schedulingStatus", { + schedulingStatus: filter.schedulingStatus, + }); + } + if (filter.search?.trim()) { + qb.andWhere( + "(booking.reference ILIKE :search OR company.name ILIKE :search)", + { search: `%${filter.search.trim()}%` }, + ); + } + return qb; + } + + async findPaginated( + filter: TransitAssignmentFilter, + skip: number, + take: number, + ): Promise<[TransitAssignment[], number]> { + return this.applyFilters(this.baseQuery(), filter) + .orderBy("ta.assignedAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + } + + /** + * One agent's own list, filtered and paginated. Differs from + * {@link findPaginated} only in that the agent is pinned by the caller from + * the session, so it can never be widened by a query parameter. + */ + async findByTransitAgentPaginated( + transitAgentId: string, + filter: Omit, + skip: number, + take: number, + ): Promise<[TransitAssignment[], number]> { + return this.applyFilters(this.baseQuery(), { ...filter, transitAgentId }) + .orderBy("ta.assignedAt", "DESC") + .skip(skip) + .take(take) + .getManyAndCount(); + } + + findOneWithRelations(id: string): Promise { + return this.baseQuery().andWhere("ta.id = :id", { id }).getOne(); + } + + /** Every live assignment for one agent — the agent's own workload list. */ + findByTransitAgent(transitAgentId: string): Promise { + return this.baseQuery() + .andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId }) + .orderBy("ta.assignedAt", "DESC") + .getMany(); + } + + /** Every live assignment on one booking. */ + findByBooking(bookingId: string): Promise { + return this.baseQuery() + .andWhere("ta.bookingId = :bookingId", { bookingId }) + .orderBy("ta.assignedAt", "DESC") + .getMany(); + } + + /** Guards the unique (booking, agent) pair before an insert 23505s. */ + async existsForPair( + bookingId: string, + transitAgentId: string, + ): Promise { + const count = await this.assignmentsRepo + .createQueryBuilder("ta") + .where("ta.bookingId = :bookingId", { bookingId }) + .andWhere("ta.transitAgentId = :transitAgentId", { transitAgentId }) + .andWhere("ta.deletedAt IS NULL") + .getCount(); + return count > 0; + } +} diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts new file mode 100644 index 000000000..68e856eff --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -0,0 +1,427 @@ +import { + ConflictException, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; + +import { + TransitAssignment, + TransitAssignmentStatus, +} from "./entities/transit-assignment.entity"; +import { TransitAssignmentsService } from "./transit-assignments.service"; + +/** + * The two things this module gets wrong quietly: the status transitions that + * stamp the clocks, and the duration computed from them. Both are invisible + * until a report reads a null or a negative number months later. + */ +describe("TransitAssignmentsService", () => { + const ARRIVED = new Date("2026-08-28T09:00:00Z"); + + let assignments: { + findPaginated: jest.Mock; + findOneWithRelations: jest.Mock; + findByTransitAgent: jest.Mock; + findByTransitAgentPaginated: jest.Mock; + findByBooking: jest.Mock; + existsForPair: jest.Mock; + create: jest.Mock; + update: jest.Mock; + softDelete: jest.Mock; + }; + let agents: { findById: jest.Mock; findByUserId: jest.Mock }; + let bookings: { findOne: jest.Mock }; + let files: { + findByResource: jest.Mock; + findByResourceIdsGrouped: jest.Mock; + upload: jest.Mock; + remove: jest.Mock; + }; + let service: TransitAssignmentsService; + let milestones: { find: jest.Mock }; + let trainSchedules: { find: jest.Mock }; + + const row = (over: Partial = {}) => + ({ + id: "ta-1", + bookingId: "bk-1", + transitAgentId: "ag-1", + status: TransitAssignmentStatus.NotStarted, + startedAt: null, + finishedAt: null, + // DISPATCHED by default: uploads are gated on it, so a fixture without it + // would fail every document test for the wrong reason. + booking: { + id: "bk-1", + arrivedAt: ARRIVED, + schedulingStatus: "DISPATCHED", + }, + ...over, + }) as TransitAssignment; + + beforeEach(() => { + assignments = { + findPaginated: jest.fn(), + findOneWithRelations: jest.fn().mockResolvedValue(row()), + findByTransitAgent: jest.fn().mockResolvedValue([]), + findByTransitAgentPaginated: jest.fn().mockResolvedValue([[], 0]), + findByBooking: jest.fn().mockResolvedValue([]), + existsForPair: jest.fn().mockResolvedValue(false), + create: jest.fn(async (data) => ({ id: "ta-1", ...data })), + update: jest.fn(async (id, data) => ({ id, ...data })), + softDelete: jest.fn(), + }; + agents = { + findById: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }), + findByUserId: jest.fn().mockResolvedValue({ id: "ag-1", name: "Ahmed" }), + }; + bookings = { findOne: jest.fn().mockResolvedValue({ id: "bk-1" }) }; + files = { + findByResource: jest.fn().mockResolvedValue([]), + findByResourceIdsGrouped: jest.fn().mockResolvedValue(new Map()), + upload: jest.fn(), + remove: jest.fn(), + }; + + milestones = { find: jest.fn().mockResolvedValue([]) }; + trainSchedules = { find: jest.fn().mockResolvedValue([]) }; + + service = new TransitAssignmentsService( + assignments as never, + agents as never, + bookings as never, + files as never, + milestones as never, + trainSchedules as never, + ); + }); + + describe("timeAfterTrainArrives", () => { + it("reports whole minutes between arrival and finish", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date("2026-08-28T14:30:00Z"), + }), + ); + + const view = await service.findById("ta-1"); + + expect(view.timeAfterTrainArrives).toBe(330); + }); + + it("is null while the work is unfinished", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ status: TransitAssignmentStatus.InProgress, startedAt: ARRIVED }), + ); + + expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull(); + }); + + it("is null when the booking never recorded an arrival", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date("2026-08-28T14:30:00Z"), + booking: { + id: "bk-1", + arrivedAt: null, + schedulingStatus: "DISPATCHED", + } as never, + }), + ); + + expect((await service.findById("ta-1")).timeAfterTrainArrives).toBeNull(); + }); + }); + + describe("status transitions", () => { + it("stamps startedAt on the move to IN_PROGRESS", async () => { + await service.update("ta-1", { + status: TransitAssignmentStatus.InProgress, + }); + + const patch = assignments.update.mock.calls[0][1]; + expect(patch.startedAt).toBeInstanceOf(Date); + expect(patch.finishedAt).toBeNull(); + }); + + it("keeps the ORIGINAL startedAt when finished work is reopened", async () => { + const original = new Date("2026-08-28T10:00:00Z"); + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + startedAt: original, + finishedAt: new Date("2026-08-28T12:00:00Z"), + }), + ); + + await service.update("ta-1", { + status: TransitAssignmentStatus.InProgress, + }); + + const patch = assignments.update.mock.calls[0][1]; + // Reopening must not restart the clock, or the elapsed time would only + // cover the second attempt rather than the whole job. + expect(patch.startedAt).toBe(original); + expect(patch.finishedAt).toBeNull(); + }); + + it("stamps both clocks when finishing work that was never started", async () => { + await service.update("ta-1", { + status: TransitAssignmentStatus.Finished, + }); + + const patch = assignments.update.mock.calls[0][1]; + expect(patch.startedAt).toBeInstanceOf(Date); + expect(patch.finishedAt).toBeInstanceOf(Date); + }); + + it("clears both clocks on a reset to NOT_STARTED", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + startedAt: ARRIVED, + finishedAt: new Date(), + }), + ); + + await service.update("ta-1", { + status: TransitAssignmentStatus.NotStarted, + }); + + const patch = assignments.update.mock.calls[0][1]; + expect(patch.startedAt).toBeNull(); + expect(patch.finishedAt).toBeNull(); + }); + }); + + describe("create", () => { + it("refuses to assign the same agent to one booking twice", async () => { + assignments.existsForPair.mockResolvedValue(true); + + await expect( + service.create({ bookingId: "bk-1", transitAgentId: "ag-1" }), + ).rejects.toThrow(ConflictException); + expect(assignments.create).not.toHaveBeenCalled(); + }); + + it("rejects an unknown booking", async () => { + bookings.findOne.mockResolvedValue(null); + + await expect( + service.create({ bookingId: "nope", transitAgentId: "ag-1" }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("files", () => { + it("refuses to delete a file belonging to another assignment", async () => { + files.findByResource.mockResolvedValue([{ id: "file-1" }]); + + await expect(service.removeFile("ta-1", "file-2")).rejects.toThrow( + NotFoundException, + ); + expect(files.remove).not.toHaveBeenCalled(); + }); + + it("names each uploaded file from its positional title", async () => { + await service.uploadFiles( + "ta-1", + [ + { originalname: "a.pdf" } as Express.Multer.File, + { originalname: "b.pdf" } as Express.Multer.File, + { originalname: "c.pdf" } as Express.Multer.File, + ], + {}, + ["Bill of lading", " ", "Packing list"], + ); + + const titles = files.upload.mock.calls.map((call) => call[0].title); + // Index N names file N; a blank entry falls back to null so the record + // shows its original filename rather than an empty label. + expect(titles).toEqual(["Bill of lading", null, "Packing list"]); + }); + + it("stores no title when none were sent", async () => { + await service.uploadFiles( + "ta-1", + [{ originalname: "a.pdf" } as Express.Multer.File], + {}, + ); + + expect(files.upload.mock.calls[0][0].title).toBeNull(); + }); + + it("refuses an upload before the booking is dispatched", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + booking: { + id: "bk-1", + arrivedAt: null, + schedulingStatus: "SCHEDULED", + } as never, + }), + ); + + await expect( + service.uploadFiles("ta-1", [{} as Express.Multer.File], {}), + ).rejects.toThrow(ForbiddenException); + expect(files.upload).not.toHaveBeenCalled(); + }); + + it("refuses an upload once the assignment is finished", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date(), + }), + ); + + await expect( + service.uploadFiles("ta-1", [{} as Express.Multer.File], {}), + ).rejects.toThrow(ForbiddenException); + }); + + it("refuses to remove a document once the assignment is finished", async () => { + assignments.findOneWithRelations.mockResolvedValue( + row({ + status: TransitAssignmentStatus.Finished, + finishedAt: new Date(), + }), + ); + files.findByResource.mockResolvedValue([{ id: "file-1" }]); + + await expect(service.removeFile("ta-1", "file-1")).rejects.toThrow( + ForbiddenException, + ); + expect(files.remove).not.toHaveBeenCalled(); + }); + }); + + describe("myStats", () => { + const at = (iso: string) => new Date(iso); + const DEPARTED = at("2026-08-27T20:00:00Z"); + + const withRows = (rows: Record[]) => { + assignments.findByTransitAgent.mockResolvedValue( + rows.map((r, i) => row({ id: `ta-${i}`, bookingId: `bk-${i}`, ...r } as never)), + ); + }; + const bookingFiles = (entries: Record>) => { + files.findByResourceIdsGrouped.mockImplementation( + async (_ids: string[], resource: string) => + resource === "bookings" + ? new Map( + Object.entries(entries).map(([bookingId, list]) => [ + bookingId, + list.map(([code, iso]) => ({ code, createdAt: at(iso) })), + ]), + ) + : new Map(), + ); + }; + + it("measures transit from the train's departure to its arrival, using the median", async () => { + withRows([ + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T06:00:00Z"), tradeDirection: "EXPORT" } }, + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-28T08:00:00Z"), tradeDirection: "EXPORT" } }, + // 3-day outlier: a mean would describe none of the three. + { booking: { loadedAt: DEPARTED, arrivedAt: at("2026-08-30T20:00:00Z"), tradeDirection: "EXPORT" } }, + // Still rolling: contributes nothing, not zero. + { booking: { loadedAt: DEPARTED, arrivedAt: null, tradeDirection: "EXPORT" } }, + ]); + bookingFiles({}); + + const stats = await service.myStats("user-1"); + + expect(stats.timings.transit).toEqual({ + median: 720, + fastest: 600, + slowest: 4320, + measured: 3, + }); + expect(stats.totals.inTransit).toBe(1); + expect(stats.totals.arrived).toBe(3); + }); + + it("times the Release Order from the declaration to the LAST RO upload", async () => { + withRows([{ booking: { tradeDirection: "EXPORT", loadedAt: null, arrivedAt: null } }]); + milestones.find.mockResolvedValue([ + { + bookingId: "bk-0", + milestoneCode: "DECLARED", + status: "COMPLETED", + triggeredAt: at("2026-08-27T08:00:00Z"), + }, + ]); + bookingFiles({ + "bk-0": [ + ["release_order_0", "2026-08-27T09:30:00Z"], + // Replaced batch — the later stamp is the one that counts. + ["release_order_1", "2026-08-27T11:00:00Z"], + ], + }); + + const stats = await service.myStats("user-1"); + const [item] = stats.items; + + expect(item.declaredAt).toBe("2026-08-27T08:00:00.000Z"); + expect(item.roAt).toBe("2026-08-27T11:00:00.000Z"); + expect(item.timings.declarationToRo).toBe(180); + expect(stats.timings.declarationToRo.median).toBe(180); + expect(item.nextAction).toEqual({ kind: "wait", label: "Awaiting train departure" }); + }); + + it("points the officer at the next upload the detail page would actually allow", async () => { + withRows([ + // Import, nothing filed: the DO comes first. + { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } }, + // Import with a DO but no departure yet: T1 is still locked. + { booking: { tradeDirection: "IMPORT", loadedAt: null, arrivedAt: null } }, + // Import, departed, no T1: upload it. + { booking: { tradeDirection: "IMPORT", loadedAt: DEPARTED, arrivedAt: null } }, + // Export, arrived with an RO but no gate pass yet. + { + booking: { + tradeDirection: "EXPORT", + loadedAt: DEPARTED, + arrivedAt: at("2026-08-28T06:00:00Z"), + }, + }, + ]); + milestones.find.mockResolvedValue([ + { bookingId: "bk-3", milestoneCode: "DECLARED", status: "COMPLETED", triggeredAt: at("2026-08-26T08:00:00Z") }, + ]); + bookingFiles({ + "bk-1": [["delivery_order_0", "2026-08-26T10:00:00Z"]], + "bk-2": [["delivery_order_0", "2026-08-26T10:00:00Z"]], + "bk-3": [["release_order_0", "2026-08-26T10:00:00Z"]], + }); + + const stats = await service.myStats("user-1"); + const byBooking = new Map(stats.items.map((i) => [i.bookingId, i])); + + expect(byBooking.get("bk-0")?.nextAction.document).toBe("do"); + expect(byBooking.get("bk-1")?.nextAction).toEqual({ + kind: "wait", + label: "Awaiting train departure", + }); + expect(byBooking.get("bk-2")?.nextAction.document).toBe("t1"); + expect(byBooking.get("bk-3")?.nextAction.document).toBe("gate_pass"); + expect(stats.pending).toEqual({ ro: 0, do: 1, t1: 1, gatePass: 1, djiboutiT1: 0 }); + expect(stats.totals.actionNeeded).toBe(3); + }); + + it("reports nulls rather than zero when nothing has been measured", async () => { + withRows([{ status: TransitAssignmentStatus.NotStarted, booking: { arrivedAt: null } }]); + bookingFiles({}); + + const stats = await service.myStats("user-1"); + + expect(stats.timings.transit.median).toBeNull(); + expect(stats.timings.arrivalToFinish.median).toBeNull(); + expect(stats.totals.open).toBe(1); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts new file mode 100644 index 000000000..80f8d6be9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -0,0 +1,937 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; +import { + isDeliveryOrderFileCode, + isDjiboutiT1FileCode, + isGatePassFileCode, + isReleaseOrderFileCode, + isT1TransportFileCode, +} from "@edr/types"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; +import { FilesService } from "../files/files.service"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository"; +import { FileRecord } from "../files/entities/file.entity"; +import { CreateTransitAssignmentDto } from "./dto/create-transit-assignment.dto"; +import { MyAssignmentsQueryDto } from "./dto/my-assignments-query.dto"; +import { TransitAssignmentQueryDto } from "./dto/transit-assignment-query.dto"; +import { UpdateTransitAssignmentDto } from "./dto/update-transit-assignment.dto"; +import { + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + TransitAssignment, + TransitAssignmentStatus, +} from "./entities/transit-assignment.entity"; +import { TransitAssignmentsRepository } from "./transit-assignments.repository"; + +/** One attached document, flattened for the API. */ +export interface TransitAssignmentFileView { + id: string; + name: string; + title: string | null; + url: string; + size: number; + mimeType: string; + /** When the file was first uploaded. */ + uploadedAt: string; + /** When its metadata was last edited — equal to `uploadedAt` if never. */ + updatedAt: string; + uploadedByUserId: string | null; + uploadedByName: string | null; +} + +export type TransitAssignmentView = TransitAssignment & { + /** + * Minutes between the train arriving and the transit work finishing — + * `finishedAt − booking.arrivedAt`, floored to whole minutes. + * + * Null until BOTH exist: an unfinished assignment has no end, and a booking + * whose arrival was never stamped has no start. Computed rather than stored + * so a corrected timestamp cannot leave a stale number behind. + */ + timeAfterTrainArrives: number | null; + /** + * Whether documents may still be added or removed right now. Mirrors + * `assertUploadAllowed` so the portal can disable its controls instead of + * letting the agent discover the rule through a 403. + */ + canUploadDocuments: boolean; + /** + * Whose cargo this is. Flattened off the joined company so the portal grid + * does not have to reach through `booking.company` — and so a booking with no + * company (shipping-line bookings carry none) renders as a blank rather than + * throwing. + */ + customerName: string | null; + files?: TransitAssignmentFileView[]; +}; + +export type TransitTradeDirection = "IMPORT" | "EXPORT"; +export type TransitDocumentKind = "ro" | "do" | "t1" | "gate_pass" | "djibouti_t1"; + +export interface TransitNextAction { + kind: "upload" | "wait" | "done"; + label: string; + document?: TransitDocumentKind; +} + +/** Minutes, or null when nothing has been measured yet — never zero. */ +export interface TransitTimingSummary { + median: number | null; + fastest: number | null; + slowest: number | null; + measured: number; +} + +export interface TransitStatItem { + id: string; + bookingId: string; + reference: string | null; + customerName: string | null; + tradeDirection: TransitTradeDirection; + status: TransitAssignmentStatus; + schedulingStatus: string | null; + trainLabel: string | null; + assignedAt: string; + startedAt: string | null; + finishedAt: string | null; + bookingCreatedAt: string | null; + departedAt: string | null; + arrivedAt: string | null; + declaredAt: string | null; + roAt: string | null; + doAt: string | null; + t1At: string | null; + t1Closed: boolean; + gatePassAt: string | null; + djiboutiT1At: string | null; + documents: { + ro: number; + do: number; + t1: number; + gatePass: number; + djiboutiT1: number; + own: number; + }; + timings: { + transit: number | null; + declarationToRo: number | null; + bookingToDo: number | null; + departureToT1: number | null; + arrivalToT1: number | null; + arrivalToGatePass: number | null; + arrivalToDjiboutiT1: number | null; + arrivalToFinish: number | null; + }; + nextAction: TransitNextAction; +} + +export interface TransitStats { + totals: { + assignments: number; + open: number; + notStarted: number; + inProgress: number; + finished: number; + imports: number; + exports: number; + awaitingDeparture: number; + inTransit: number; + arrived: number; + actionNeeded: number; + }; + timings: Record; + documents: TransitStatItem["documents"]; + pending: { ro: number; do: number; t1: number; gatePass: number; djiboutiT1: number }; + items: TransitStatItem[]; +} + +@Injectable() +export class TransitAssignmentsService { + constructor( + private readonly assignmentsRepository: TransitAssignmentsRepository, + private readonly transitAgentsRepository: TransitAgentsRepository, + // The Booking ENTITY, not BookingsModule: this only needs to confirm a + // booking id exists, and importing that module would pull its whole graph + // (billing, contracts, scheduling, first/last mile) in behind it. + @InjectRepository(Booking) + private readonly bookingsRepository: Repository, + private readonly filesService: FilesService, + @InjectRepository(ClearanceMilestone) + private readonly milestonesRepository: Repository, + @InjectRepository(TrainSchedule) + private readonly trainSchedulesRepository: Repository, + ) {} + + private static minutesBetween( + from?: Date | null, + to?: Date | null, + ): number | null { + if (!from || !to) return null; + return Math.floor((to.getTime() - from.getTime()) / 60_000); + } + + private toView(assignment: TransitAssignment): TransitAssignmentView { + return { + ...assignment, + timeAfterTrainArrives: TransitAssignmentsService.minutesBetween( + assignment.booking?.arrivedAt, + assignment.finishedAt, + ), + canUploadDocuments: + assignment.status !== TransitAssignmentStatus.Finished && + assignment.booking?.schedulingStatus === "DISPATCHED", + customerName: assignment.booking?.company?.name ?? null, + }; + } + + async findAll(query: TransitAssignmentQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const [items, total] = await this.assignmentsRepository.findPaginated( + { + bookingId: query.bookingId, + transitAgentId: query.transitAgentId, + status: query.status, + }, + (page - 1) * pageSize, + pageSize, + ); + + return { + items: items.map((item) => this.toView(item)), + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + /** Detail read — the only one that carries the attached documents. */ + async findById(id: string): Promise { + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + return { ...this.toView(assignment), files: await this.listFiles(id) }; + } + + /** Every assignment handed to one transit agent — their workload list. */ + async findByTransitAgent( + transitAgentId: string, + ): Promise { + const agent = await this.transitAgentsRepository.findById(transitAgentId); + if (!agent) { + throw new NotFoundException(`Transit agent ${transitAgentId} not found`); + } + const rows = + await this.assignmentsRepository.findByTransitAgent(transitAgentId); + return rows.map((row) => this.toView(row)); + } + + /** Every agent assigned to one booking. */ + async findByBooking(bookingId: string): Promise { + const rows = await this.assignmentsRepository.findByBooking(bookingId); + return rows.map((row) => this.toView(row)); + } + + // ── Portal (the signed-in transit agent's own work) ─────────────────────── + // Every one of these resolves the agent from the SESSION and never from a + // client-supplied id: an agent must not be able to read or edit another + // agent's assignments by guessing one. + + /** The transit agent this portal user signs in as. */ + private async requireAgentForUser(userId: string) { + const agent = await this.transitAgentsRepository.findByUserId(userId); + if (!agent) { + throw new ForbiddenException("This account is not a transit agent"); + } + return agent; + } + + /** + * Dashboard figures for the signed-in agent's own work. + * + * Every interval is derived from timestamps that already exist — nothing is + * stored, so a corrected arrival or finish time changes these on the next + * read rather than leaving a stale metric behind. + * + * The median is used rather than the mean on purpose: one assignment + * reopened days later drags an average far enough to make the whole panel + * lie about typical performance. + */ + /** + * The agent's dashboard, every figure derived from stamps that already exist: + * the train's departure and arrival, the booking's clearance milestones, and + * the upload time of each document on the booking (RO / DO / T1 / gate pass / + * Djibouti T1). Replaced batches carry a fresh stamp, so an "uploaded" time + * here is always the LAST update, matching the detail page. + * + * Nothing is stored: a corrected timestamp cannot leave a stale number behind. + */ + async myStats(userId: string): Promise { + const agent = await this.requireAgentForUser(userId); + const rows = await this.assignmentsRepository.findByTransitAgent(agent.id); + const bookingIds = [...new Set(rows.map((r) => r.bookingId))]; + + const [ownDocs, bookingDocs, milestones, schedules] = await Promise.all([ + rows.length + ? this.filesService.findByResourceIdsGrouped( + rows.map((r) => r.id), + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ) + : new Map(), + bookingIds.length + ? this.filesService.findByResourceIdsGrouped(bookingIds, "bookings") + : new Map(), + bookingIds.length + ? this.milestonesRepository.find({ + where: { bookingId: In(bookingIds) }, + select: ["bookingId", "milestoneCode", "status", "triggeredAt"], + }) + : [], + (() => { + const ids = [ + ...new Set( + rows + .map((r) => r.booking?.trainScheduleId) + .filter((id): id is string => Boolean(id)), + ), + ]; + return ids.length + ? this.trainSchedulesRepository.find({ + where: { id: In(ids) }, + select: [ + "id", + "trainNumber", + "voyageNumber", + "actualDepartureAt", + "actualArrivalAt", + ], + }) + : []; + })(), + ]); + + const scheduleById = new Map(schedules.map((sch) => [sch.id, sch])); + const milestonesByBooking = new Map(); + for (const m of milestones) { + if (!m.bookingId) continue; + const bucket = milestonesByBooking.get(m.bookingId); + if (bucket) bucket.push(m); + else milestonesByBooking.set(m.bookingId, [m]); + } + + const iso = (d?: Date | string | null): string | null => + d ? new Date(d).toISOString() : null; + const minutes = (from?: string | null, to?: string | null): number | null => + from && to + ? Math.floor((new Date(to).getTime() - new Date(from).getTime()) / 60_000) + : null; + /** Latest upload stamp among files matching a code family. */ + const latest = ( + files: FileRecord[], + matches: (code: string | null | undefined) => boolean, + ): { at: string | null; count: number } => { + const hits = files.filter((f) => matches(f.code)); + return { + count: hits.length, + at: hits.reduce((max, f) => { + const stamp = iso(f.createdAt); + return stamp && (!max || stamp > max) ? stamp : max; + }, null), + }; + }; + /** Earliest upload stamp — for append-only sets the FIRST document matters. */ + const earliest = ( + files: FileRecord[], + matches: (code: string | null | undefined) => boolean, + ): { at: string | null; count: number } => { + const hits = files.filter((f) => matches(f.code)); + return { + count: hits.length, + at: hits.reduce((min, f) => { + const stamp = iso(f.createdAt); + return stamp && (!min || stamp < min) ? stamp : min; + }, null), + }; + }; + + const items: TransitStatItem[] = rows.map((row) => { + const booking = row.booking; + const tradeDirection: TransitTradeDirection = + booking?.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT"; + const schedule = booking?.trainScheduleId + ? scheduleById.get(booking.trainScheduleId) + : undefined; + + // Same rule as the clearance view's train state: the booking's own + // load/unload stamps first, the schedule's actuals only as a fallback for + // legacy bookings that predate per-booking loading. + const departedAt = iso(booking?.loadedAt ?? schedule?.actualDepartureAt); + const arrivedAt = iso( + booking?.arrivedAt ?? + (booking?.loadedAt ? null : schedule?.actualArrivalAt), + ); + + const files = bookingDocs.get(row.bookingId) ?? []; + const ms = milestonesByBooking.get(row.bookingId) ?? []; + const milestone = (code: string) => ms.find((m) => m.milestoneCode === code); + const done = (code: string) => { + const m = milestone(code); + return m?.status === "COMPLETED" || m?.status === "SKIPPED"; + }; + + const declared = done("DECLARED"); + const declaredAt = iso(milestone("DECLARED")?.triggeredAt); + const ro = latest(files, isReleaseOrderFileCode); + const deliveryOrder = latest(files, isDeliveryOrderFileCode); + const t1 = latest(files, isT1TransportFileCode); + const gatePass = earliest(files, isGatePassFileCode); + const djiboutiT1 = earliest(files, isDjiboutiT1FileCode); + const t1Closed = milestone("T1_CLOSED")?.status === "COMPLETED"; + const bookingCreatedAt = iso(booking?.createdAt); + const finishedAt = iso(row.finishedAt); + const finished = row.status === TransitAssignmentStatus.Finished; + + const timings: TransitStatItem["timings"] = { + transit: minutes(departedAt, arrivedAt), + declarationToRo: tradeDirection === "EXPORT" ? minutes(declaredAt, ro.at) : null, + bookingToDo: + tradeDirection === "IMPORT" ? minutes(bookingCreatedAt, deliveryOrder.at) : null, + departureToT1: tradeDirection === "IMPORT" ? minutes(departedAt, t1.at) : null, + arrivalToT1: tradeDirection === "IMPORT" ? minutes(arrivedAt, t1.at) : null, + arrivalToGatePass: + tradeDirection === "EXPORT" ? minutes(arrivedAt, gatePass.at) : null, + arrivalToDjiboutiT1: + tradeDirection === "EXPORT" ? minutes(arrivedAt, djiboutiT1.at) : null, + arrivalToFinish: minutes(arrivedAt, finishedAt), + }; + + // What the officer should do next on this shipment — the same gates the + // detail page enforces, so the dashboard never points at a locked button. + let nextAction: TransitNextAction; + if (finished) { + nextAction = { kind: "done", label: "Assignment finished" }; + } else if (tradeDirection === "EXPORT") { + if (!declared) { + nextAction = { kind: "wait", label: "Awaiting customs declaration" }; + } else if (ro.count === 0) { + nextAction = { kind: "upload", label: "Upload Release Order", document: "ro" }; + } else if (!departedAt) { + nextAction = { kind: "wait", label: "Awaiting train departure" }; + } else if (!arrivedAt) { + nextAction = { kind: "wait", label: "Train in transit" }; + } else if (gatePass.count === 0) { + nextAction = { kind: "upload", label: "Upload gate pass", document: "gate_pass" }; + } else if (djiboutiT1.count === 0) { + nextAction = { + kind: "upload", + label: "Upload Djibouti T1", + document: "djibouti_t1", + }; + } else { + nextAction = { kind: "done", label: "Paperwork complete" }; + } + } else if (deliveryOrder.count === 0) { + nextAction = { kind: "upload", label: "Upload Delivery Order", document: "do" }; + } else if (!departedAt) { + nextAction = { kind: "wait", label: "Awaiting train departure" }; + } else if (t1.count === 0 && !t1Closed) { + nextAction = { kind: "upload", label: "Upload T1 documents", document: "t1" }; + } else if (!arrivedAt) { + nextAction = { kind: "wait", label: "Train in transit" }; + } else { + nextAction = { kind: "done", label: t1Closed ? "T1 closed" : "Paperwork complete" }; + } + + return { + id: row.id, + bookingId: row.bookingId, + reference: booking?.reference ?? null, + customerName: booking?.company?.name ?? null, + tradeDirection, + status: row.status, + schedulingStatus: booking?.schedulingStatus ?? null, + trainLabel: schedule?.voyageNumber ?? schedule?.trainNumber ?? null, + assignedAt: iso(row.assignedAt) ?? new Date(0).toISOString(), + startedAt: iso(row.startedAt), + finishedAt, + bookingCreatedAt, + departedAt, + arrivedAt, + declaredAt, + roAt: ro.at, + doAt: deliveryOrder.at, + t1At: t1.at, + t1Closed, + gatePassAt: gatePass.at, + djiboutiT1At: djiboutiT1.at, + documents: { + ro: ro.count, + do: deliveryOrder.count, + t1: t1.count, + gatePass: gatePass.count, + djiboutiT1: djiboutiT1.count, + own: (ownDocs.get(row.id) ?? []).length, + }, + timings, + nextAction, + }; + }); + + // Most recently moving shipment first: arrival, else departure, else when + // it was handed to the agent. + const activity = (i: TransitStatItem) => + i.arrivedAt ?? i.departedAt ?? i.assignedAt; + items.sort((a, b) => activity(b).localeCompare(activity(a))); + + const summarize = (values: Array): TransitTimingSummary => { + const measured = values.filter((v): v is number => v !== null && v >= 0); + if (!measured.length) { + return { median: null, fastest: null, slowest: null, measured: 0 }; + } + const sorted = [...measured].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return { + median: + sorted.length % 2 + ? sorted[mid] + : Math.round((sorted[mid - 1] + sorted[mid]) / 2), + fastest: sorted[0], + slowest: sorted[sorted.length - 1], + measured: sorted.length, + }; + }; + const timing = (key: keyof TransitStatItem["timings"]) => + summarize(items.map((i) => i.timings[key])); + + const open = items.filter((i) => i.status !== TransitAssignmentStatus.Finished); + const pendingFor = (document: TransitDocumentKind) => + items.filter( + (i) => i.nextAction.kind === "upload" && i.nextAction.document === document, + ).length; + const sumDocs = (key: keyof TransitStatItem["documents"]) => + items.reduce((sum, i) => sum + i.documents[key], 0); + + return { + totals: { + assignments: items.length, + open: open.length, + notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted) + .length, + inProgress: items.filter((i) => i.status === TransitAssignmentStatus.InProgress) + .length, + finished: items.length - open.length, + imports: items.filter((i) => i.tradeDirection === "IMPORT").length, + exports: items.filter((i) => i.tradeDirection === "EXPORT").length, + awaitingDeparture: open.filter((i) => !i.departedAt).length, + inTransit: open.filter((i) => i.departedAt && !i.arrivedAt).length, + arrived: open.filter((i) => Boolean(i.arrivedAt)).length, + actionNeeded: items.filter((i) => i.nextAction.kind === "upload").length, + }, + timings: { + transit: timing("transit"), + declarationToRo: timing("declarationToRo"), + bookingToDo: timing("bookingToDo"), + departureToT1: timing("departureToT1"), + arrivalToT1: timing("arrivalToT1"), + arrivalToGatePass: timing("arrivalToGatePass"), + arrivalToDjiboutiT1: timing("arrivalToDjiboutiT1"), + arrivalToFinish: timing("arrivalToFinish"), + }, + documents: { + ro: sumDocs("ro"), + do: sumDocs("do"), + t1: sumDocs("t1"), + gatePass: sumDocs("gatePass"), + djiboutiT1: sumDocs("djiboutiT1"), + own: sumDocs("own"), + }, + pending: { + ro: pendingFor("ro"), + do: pendingFor("do"), + t1: pendingFor("t1"), + gatePass: pendingFor("gate_pass"), + djiboutiT1: pendingFor("djibouti_t1"), + }, + items: items.slice(0, 20), + }; + } + + async findMine(userId: string, query: MyAssignmentsQueryDto = {}) { + const agent = await this.requireAgentForUser(userId); + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + + const [rows, total] = + await this.assignmentsRepository.findByTransitAgentPaginated( + agent.id, + { + status: query.status, + schedulingStatus: query.schedulingStatus, + search: query.search, + }, + (page - 1) * pageSize, + pageSize, + ); + + // Documents come back with the list so the grid can show a per-row count. + // Batched deliberately: one lookup for the page, not one per assignment. + const grouped = rows.length + ? await this.filesService.findByResourceIdsGrouped( + rows.map((row) => row.id), + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ) + : new Map(); + + return { + items: rows.map((row) => ({ + ...this.toView(row), + files: (grouped.get(row.id) ?? []).map((record: FileRecord) => ({ + id: record.id, + name: record.name, + title: record.title, + url: record.url, + size: record.size, + mimeType: record.mimeType, + uploadedAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + uploadedByUserId: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + })), + })), + meta: { + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }, + }; + } + + /** + * One of the signed-in agent's own assignments, with its documents. + * Ownership is asserted rather than filtered: a mismatch is hidden behind a + * NotFound so assignment ids cannot be probed. + */ + async findMineById( + userId: string, + id: string, + ): Promise { + const agent = await this.requireAgentForUser(userId); + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment || assignment.transitAgentId !== agent.id) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + return { ...this.toView(assignment), files: await this.listFiles(id) }; + } + + /** Assert the assignment is this user's before any write reaches it. */ + private async assertMine(userId: string, id: string): Promise { + await this.findMineById(userId, id); + } + + async uploadMyFiles( + userId: string, + id: string, + files: Express.Multer.File[], + uploader: { userId?: string; name?: string }, + titles?: string[], + ): Promise { + await this.assertMine(userId, id); + return this.uploadFiles(id, files, uploader, titles); + } + + async removeMyFile( + userId: string, + id: string, + fileId: string, + ): Promise { + await this.assertMine(userId, id); + return this.removeFile(id, fileId); + } + + /** + * The portal's Save / Finish action. + * + * Save keeps the assignment open (moving it to IN_PROGRESS so the work reads + * as under way); Finish closes it, which also locks its documents — see + * `assertUploadAllowed`. + */ + async submitMine( + userId: string, + id: string, + input: { finish: boolean; note?: string }, + ): Promise { + const current = await this.findMineById(userId, id); + if (current.status === TransitAssignmentStatus.Finished) { + throw new ForbiddenException("This assignment is already finished."); + } + await this.update(id, { + status: input.finish + ? TransitAssignmentStatus.Finished + : TransitAssignmentStatus.InProgress, + note: input.note, + }); + return this.findMineById(userId, id); + } + + /** + * Make `transitAgentId` the officer working `bookingId`, as the clearance + * desk's "assign transit assignee" step means it. + * + * The booking itself only records the officer's NAME, which is all the + * clearance UI needs; the officer's own portal reads `transit_assignments`. + * This keeps the two in step, and is deliberately forgiving where `create()` + * is strict: + * - assigning the same agent twice is a no-op, not a 409 — the desk may + * re-save the step without meaning to start over; + * - a REASSIGNMENT retires the previous officer's row, so a shipment does + * not sit in the work list of someone who no longer handles it. Finished + * rows stay, since they are that officer's record of work already done. + */ + async ensureAssignment( + bookingId: string, + transitAgentId: string, + assignedByUserId?: string, + ): Promise { + const existing = + await this.assignmentsRepository.findByBooking(bookingId); + + for (const row of existing) { + if ( + row.transitAgentId !== transitAgentId && + row.status !== TransitAssignmentStatus.Finished + ) { + await this.assignmentsRepository.softDelete(row.id); + } + } + + if (existing.some((row) => row.transitAgentId === transitAgentId)) return; + + await this.assignmentsRepository.create({ + bookingId, + transitAgentId, + status: TransitAssignmentStatus.NotStarted, + startedAt: null, + finishedAt: null, + assignedByUserId: assignedByUserId ?? null, + note: null, + }); + } + + async create( + dto: CreateTransitAssignmentDto, + assignedByUserId?: string, + ): Promise { + const booking = await this.bookingsRepository.findOne({ + where: { id: dto.bookingId }, + select: { id: true }, + }); + if (!booking) { + throw new NotFoundException(`Booking ${dto.bookingId} not found`); + } + const agent = await this.transitAgentsRepository.findById( + dto.transitAgentId, + ); + if (!agent) { + throw new NotFoundException( + `Transit agent ${dto.transitAgentId} not found`, + ); + } + if ( + await this.assignmentsRepository.existsForPair( + dto.bookingId, + dto.transitAgentId, + ) + ) { + throw new ConflictException( + `${agent.name} is already assigned to this booking`, + ); + } + + const status = dto.status ?? TransitAssignmentStatus.NotStarted; + const created = await this.assignmentsRepository.create({ + bookingId: dto.bookingId, + transitAgentId: dto.transitAgentId, + status, + // Creating straight into a working state still has to stamp its clock, or + // the assignment would report no start. + startedAt: + status === TransitAssignmentStatus.NotStarted ? null : new Date(), + finishedAt: + status === TransitAssignmentStatus.Finished ? new Date() : null, + assignedByUserId: assignedByUserId ?? null, + note: dto.note?.trim() || null, + }); + + return this.findById(created.id); + } + + async update( + id: string, + dto: UpdateTransitAssignmentDto, + ): Promise { + const current = await this.assignmentsRepository.findOneWithRelations(id); + if (!current) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + + const patch: Partial = {}; + if (dto.note !== undefined) patch.note = dto.note.trim() || null; + + if (dto.status && dto.status !== current.status) { + patch.status = dto.status; + if (dto.status === TransitAssignmentStatus.InProgress) { + // Only the FIRST start is recorded — reopening finished work keeps the + // original start, so the elapsed time still spans the whole job. + patch.startedAt = current.startedAt ?? new Date(); + patch.finishedAt = null; + } else if (dto.status === TransitAssignmentStatus.Finished) { + patch.startedAt = current.startedAt ?? new Date(); + patch.finishedAt = new Date(); + } else { + // Back to NOT_STARTED — the work is being reset, so both clocks clear + // rather than leaving a duration for work that no longer happened. + patch.startedAt = null; + patch.finishedAt = null; + } + } + + const updated = await this.assignmentsRepository.update(id, patch); + if (!updated) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + return this.findById(id); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.assignmentsRepository.softDelete(id); + } + + // ── Documents ───────────────────────────────────────────────────────────── + // Stored in `freight.files` under TRANSIT_ASSIGNMENT_FILE_RESOURCE rather + // than a table of their own: that one already carries the MinIO object, the + // upload time, the uploader and the supersede history. + + /** + * Whether an assignment may still receive documents. + * + * Two gates, both business rules rather than UI conveniences: + * - the booking must actually be on its way (`DISPATCHED`), since there is + * nothing to clear before the train leaves; + * - the assignment must not be FINISHED — filing closes with the work, so a + * finished record cannot grow new paperwork afterwards. + */ + private assertUploadAllowed(assignment: TransitAssignment): void { + if (assignment.status === TransitAssignmentStatus.Finished) { + throw new ForbiddenException( + "This assignment is finished — its documents can no longer be changed.", + ); + } + if (assignment.booking?.schedulingStatus !== "DISPATCHED") { + throw new ForbiddenException( + "Documents can only be uploaded once the booking has been dispatched.", + ); + } + } + + async listFiles(id: string): Promise { + const records = await this.filesService.findByResource( + id, + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ); + return records.map((record) => ({ + id: record.id, + name: record.name, + title: record.title, + url: record.url, + size: record.size, + mimeType: record.mimeType, + uploadedAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + uploadedByUserId: record.uploadedByUserId, + uploadedByName: record.uploadedByName, + })); + } + + async uploadFiles( + id: string, + files: Express.Multer.File[], + uploader: { userId?: string; name?: string }, + /** + * A display name per file, positionally matched to `files`. Multer preserves + * the multipart part order, and the client appends one `titles` entry per + * file in the same order, so index N names file N. A missing or blank entry + * falls back to the original filename. + */ + titles?: string[], + ): Promise { + if (!files?.length) { + throw new BadRequestException("No files were uploaded"); + } + // Asserts the assignment exists before anything reaches MinIO — an upload + // keyed to a missing row would be unreachable storage nobody ever lists. + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + this.assertUploadAllowed(assignment); + + await Promise.all( + files.map((file, index) => + this.filesService.upload({ + resourceId: id, + resource: TRANSIT_ASSIGNMENT_FILE_RESOURCE, + code: file.fieldname || "document", + file, + title: titles?.[index]?.trim() || null, + uploadedByUserId: uploader.userId ?? null, + uploadedByName: uploader.name ?? null, + }), + ), + ); + + return this.listFiles(id); + } + + async removeFile(id: string, fileId: string): Promise { + const assignment = + await this.assignmentsRepository.findOneWithRelations(id); + if (!assignment) { + throw new NotFoundException(`Transit assignment ${id} not found`); + } + // Same gate as upload: a finished assignment's paperwork is fixed, and + // removal is as much a change as adding. + this.assertUploadAllowed(assignment); + + const files = await this.filesService.findByResource( + id, + TRANSIT_ASSIGNMENT_FILE_RESOURCE, + ); + // Scoped to this assignment's own documents: a bare file id would let one + // assignment delete another's paperwork. + if (!files.some((file) => file.id === fileId)) { + throw new NotFoundException( + `File ${fileId} not found on this assignment`, + ); + } + await this.filesService.remove(fileId); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts b/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts new file mode 100644 index 000000000..3ad9748d4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/dto/wagon-history-query.dto.ts @@ -0,0 +1,47 @@ +import { WagonEventCategory, WagonEventType } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsDateString, IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class WagonHistoryQueryDto { + @ApiPropertyOptional({ enum: WagonEventCategory, description: 'Only events of this category' }) + @IsOptional() + @IsEnum(WagonEventCategory) + category?: WagonEventCategory; + + @ApiPropertyOptional({ + enum: WagonEventType, + isArray: true, + description: 'Only these event types (repeat the param or comma-separate)', + }) + @IsOptional() + @Transform(({ value }) => + Array.isArray(value) ? value : String(value).split(',').map((v) => v.trim()).filter(Boolean), + ) + @IsArray() + @IsEnum(WagonEventType, { each: true }) + types?: WagonEventType[]; + + @ApiPropertyOptional({ description: 'ISO timestamp — events at or after this moment' }) + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ description: 'ISO timestamp — events at or before this moment' }) + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ description: 'Opaque `nextCursor` from the previous page' }) + @IsOptional() + @IsString() + cursor?: string; + + @ApiPropertyOptional({ default: 50, minimum: 1, maximum: 200 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit?: number; +} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts new file mode 100644 index 000000000..c0dedf760 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-event.entity.ts @@ -0,0 +1,74 @@ +import { WagonEventCategory, WagonEventType } from '@edr/types'; +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * Append-only history of everything that happens to a wagon — one row per + * wagon per transition, written inside the same transaction as the change. + * Plain id columns, no foreign keys and no soft delete on purpose: the history + * must outlive the wagon, train, schedule or booking it refers to, exactly like + * `audit_logs` and `schedule_wagon_adjustment_logs`. Rows are never updated. + * + * Read path: `(wagon_id, occurred_at DESC, id DESC)` keyset pagination — one + * index range scan per page regardless of how long the wagon has been in + * service. Labels (yard, train, schedule, booking, actor) are joined at read + * time on primary keys, so the write path stays a single INSERT. + */ +@Entity({ schema: 'freight', name: 'wagon_events' }) +@Index('idx_wagon_events_wagon_time', ['wagonId', 'occurredAt', 'id']) +@Index('idx_wagon_events_wagon_cat_time', ['wagonId', 'category', 'occurredAt', 'id']) +export class WagonEvent { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + /** Snapshot so the row still reads after the wagon is purged or renumbered. */ + @Column({ name: 'wagon_number', type: 'varchar', nullable: true }) + wagonNumber?: string | null; + + @Column({ name: 'event_type', type: 'varchar', length: 40 }) + type!: WagonEventType; + + /** Derived from `type` at write time; stored so the category filter hits the index. */ + @Column({ name: 'category', type: 'varchar', length: 20 }) + category!: WagonEventCategory; + + @Column({ name: 'occurred_at', type: 'timestamptz' }) + occurredAt!: Date; + + @Column({ name: 'actor_user_id', type: 'uuid', nullable: true }) + actorUserId?: string | null; + + @Column({ name: 'from_yard_id', type: 'uuid', nullable: true }) + fromYardId?: string | null; + + @Column({ name: 'to_yard_id', type: 'uuid', nullable: true }) + toYardId?: string | null; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId?: string | null; + + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + /** Previous value of whatever the event changed (status, sequence, train code…). */ + @Column({ name: 'from_value', type: 'varchar', length: 120, nullable: true }) + fromValue?: string | null; + + @Column({ name: 'to_value', type: 'varchar', length: 120, nullable: true }) + toValue?: string | null; + + /** Staff-entered reason / note, when the action carried one. */ + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts new file mode 100644 index 000000000..d049578d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; + +import { WagonHistoryService } from './wagon-history.service'; + +/** + * Global, dependency-free (only the DataSource): every service that writes a + * wagon row — wagons desk, train builder, scheduling, booking journey, + * containers, cancellations — records history through WagonHistoryService + * without adding a module edge, the same pattern as FleetHistoryModule. + */ +@Global() +@Module({ + providers: [WagonHistoryService], + exports: [WagonHistoryService], +}) +export class WagonHistoryModule {} diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts new file mode 100644 index 000000000..0855d7c6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.spec.ts @@ -0,0 +1,135 @@ +import { BadRequestException } from '@nestjs/common'; +import { WagonEventCategory, WagonEventType } from '@edr/types'; + +import { WagonHistoryService } from './wagon-history.service'; + +/** Captures the INSERT query-builder chain and the raw list query. */ +function makeDataSource() { + const execute = jest.fn().mockResolvedValue(undefined); + const values = jest.fn(); + const chain = { insert: jest.fn(), into: jest.fn(), values, updateEntity: jest.fn(), execute }; + chain.insert.mockReturnValue(chain); + chain.into.mockReturnValue(chain); + values.mockReturnValue(chain); + chain.updateEntity.mockReturnValue(chain); + const manager = { createQueryBuilder: jest.fn(() => chain) }; + const query = jest.fn().mockResolvedValue([]); + return { dataSource: { manager, query }, manager, values, execute, query }; +} + +describe('WagonHistoryService.record', () => { + it('writes a batch as one INSERT, deriving the category from the type', async () => { + const { dataSource, values, execute } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + const at = new Date('2026-09-01T10:00:00Z'); + + await service.record(dataSource.manager as never, [ + { wagonId: 'w1', wagonNumber: 'W-1', type: WagonEventType.MovedManually, toYardId: 'y2', occurredAt: at }, + { wagonId: 'w2', type: WagonEventType.CargoLoaded, bookingId: 'b1', toValue: 12.5 }, + null, + ]); + + expect(execute).toHaveBeenCalledTimes(1); + const rows = values.mock.calls[0][0]; + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + wagonId: 'w1', + wagonNumber: 'W-1', + type: WagonEventType.MovedManually, + category: WagonEventCategory.Yard, + toYardId: 'y2', + occurredAt: at, + actorUserId: null, + }); + expect(rows[1]).toMatchObject({ + wagonId: 'w2', + category: WagonEventCategory.Cargo, + bookingId: 'b1', + toValue: '12.5', + }); + expect(rows[1].occurredAt).toBeInstanceOf(Date); + }); + + it('skips empty input without touching the database', async () => { + const { dataSource, execute } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + await service.record(dataSource.manager as never, []); + await service.record(null, null); + expect(execute).not.toHaveBeenCalled(); + }); + + it('propagates a failure inside a caller transaction but swallows it outside one', async () => { + const { dataSource, execute } = makeDataSource(); + execute.mockRejectedValue(new Error('db down')); + const service = new WagonHistoryService(dataSource as never); + const input = { wagonId: 'w1', type: WagonEventType.Registered }; + + await expect(service.record(dataSource.manager as never, input)).rejects.toThrow('db down'); + await expect(service.record(null, input)).resolves.toBeUndefined(); + }); +}); + +describe('WagonHistoryService.list', () => { + const A = '11111111-1111-4111-8111-111111111111'; + const B = '22222222-2222-4222-8222-222222222222'; + const C = '33333333-3333-4333-8333-333333333333'; + const row = (id: string, at: string) => ({ + id, + wagonId: 'w1', + wagonNumber: 'W-1', + type: WagonEventType.PassedCheckpoint, + category: WagonEventCategory.Yard, + occurredAt: new Date(at), + actorUserId: null, + actorName: null, + fromYardId: 'y1', + fromYardLabel: 'Origin', + toYardId: 'y2', + toYardLabel: 'Stop', + trainId: null, + trainCode: null, + trainScheduleId: 's1', + scheduleLabel: 'V-100', + bookingId: null, + bookingReference: null, + fromValue: null, + toValue: null, + reason: null, + metadata: null, + }); + + it('returns a page with a cursor when more rows exist, and decodes that cursor on the next call', async () => { + const { dataSource, query } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + query.mockResolvedValueOnce([ + row(A, '2026-09-01T10:00:00Z'), + row(B, '2026-09-01T09:00:00Z'), + row(C, '2026-09-01T08:00:00Z'), // the +1 probe row + ]); + + const first = await service.list('w1', { limit: 2, category: WagonEventCategory.Yard }); + expect(first.items.map((i) => i.id)).toEqual([A, B]); + expect(first.items[0].occurredAt).toBe('2026-09-01T10:00:00.000Z'); + expect(first.nextCursor).toEqual(expect.any(String)); + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain('e.wagon_id = $1'); + expect(sql).toContain('e.category = $2'); + expect(sql).toContain('LIMIT 3'); + expect(params).toEqual(['w1', WagonEventCategory.Yard]); + + query.mockResolvedValueOnce([row(C, '2026-09-01T08:00:00Z')]); + const second = await service.list('w1', { limit: 2, cursor: first.nextCursor! }); + expect(second.items.map((i) => i.id)).toEqual([C]); + expect(second.nextCursor).toBeNull(); + const [sql2, params2] = query.mock.calls[1]; + expect(sql2).toContain('(e.occurred_at, e.id) < ($2, $3::uuid)'); + expect(params2[1]).toEqual(new Date('2026-09-01T09:00:00Z')); + expect(params2[2]).toBe(B); + }); + + it('rejects a malformed cursor', async () => { + const { dataSource } = makeDataSource(); + const service = new WagonHistoryService(dataSource as never); + await expect(service.list('w1', { cursor: 'not-a-cursor' })).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts new file mode 100644 index 000000000..982e927ce --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-history/wagon-history.service.ts @@ -0,0 +1,195 @@ +import { + WAGON_EVENT_CATEGORY, + WagonEventCategory, + WagonEventType, + WagonHistoryEvent, + WagonHistoryPage, +} from '@edr/types'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { WagonHistoryQueryDto } from './dto/wagon-history-query.dto'; +import { WagonEvent } from './wagon-event.entity'; + +/** One transition to append. Everything but the wagon and the type is optional context. */ +export interface WagonEventInput { + wagonId: string; + /** Snapshot for the row; pass it when the caller already holds the wagon (no lookup is made). */ + wagonNumber?: string | null; + type: WagonEventType; + /** Defaults to now. Pass the business timestamp when the caller has one. */ + occurredAt?: Date | null; + actorUserId?: string | null; + fromYardId?: string | null; + toYardId?: string | null; + trainId?: string | null; + trainScheduleId?: string | null; + bookingId?: string | null; + fromValue?: string | number | null; + toValue?: string | number | null; + reason?: string | null; + metadata?: Record | null; +} + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 200; + +/** + * The single write and read path for `freight.wagon_events`. + * + * Writes: {@link record} takes the caller's EntityManager so the history row + * commits (or rolls back) with the business change — a wagon can never end up + * moved without its history row or vice versa. A batch is one INSERT. + * + * Reads: {@link list} is keyset-paginated on `(occurred_at, id)` under the + * per-wagon index, so page N costs the same as page 1; labels come from + * primary-key LEFT JOINs on the page only. + */ +@Injectable() +export class WagonHistoryService { + private readonly logger = new Logger(WagonHistoryService.name); + + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * Append one or more events. Inside a transaction (manager given) a failure + * propagates — Postgres has already aborted the transaction at that point, + * so swallowing it would only hide the rollback. Outside a transaction the + * write is best-effort: logged, never thrown, so history can't break the + * operation that produced it. + */ + async record( + manager: EntityManager | null | undefined, + input: WagonEventInput | null | Array, + ): Promise { + const inputs = (Array.isArray(input) ? input : [input]).filter( + (i): i is WagonEventInput => Boolean(i?.wagonId), + ); + if (!inputs.length) return; + const now = new Date(); + const rows = inputs.map((i) => ({ + wagonId: i.wagonId, + wagonNumber: i.wagonNumber ?? null, + type: i.type, + category: WAGON_EVENT_CATEGORY[i.type] ?? WagonEventCategory.Lifecycle, + occurredAt: i.occurredAt ?? now, + actorUserId: i.actorUserId ?? null, + fromYardId: i.fromYardId ?? null, + toYardId: i.toYardId ?? null, + trainId: i.trainId ?? null, + trainScheduleId: i.trainScheduleId ?? null, + bookingId: i.bookingId ?? null, + fromValue: i.fromValue == null ? null : String(i.fromValue).slice(0, 120), + toValue: i.toValue == null ? null : String(i.toValue).slice(0, 120), + reason: i.reason?.trim() ? i.reason.trim() : null, + metadata: i.metadata ?? null, + })); + const mg = manager ?? this.dataSource.manager; + const write = () => + mg + .createQueryBuilder() + .insert() + .into(WagonEvent) + .values(rows as unknown as QueryDeepPartialEntity[]) + .updateEntity(false) + .execute(); + if (manager) { + await write(); + return; + } + try { + await write(); + } catch (err) { + this.logger.error( + `Failed to record ${rows.length} wagon event(s) (${rows[0].type}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + /** One wagon's timeline, newest first, with labels resolved. */ + async list(wagonId: string, query: WagonHistoryQueryDto = {}): Promise { + const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT); + const params: unknown[] = [wagonId]; + const where: string[] = ['e.wagon_id = $1']; + const push = (value: unknown) => { + params.push(value); + return `$${params.length}`; + }; + if (query.category) where.push(`e.category = ${push(query.category)}`); + if (query.types?.length) where.push(`e.event_type = ANY(${push(query.types)}::text[])`); + if (query.from) where.push(`e.occurred_at >= ${push(new Date(query.from))}`); + if (query.to) where.push(`e.occurred_at <= ${push(new Date(query.to))}`); + const cursor = decodeCursor(query.cursor); + if (cursor) { + // Row-value comparison walks the (wagon_id, occurred_at DESC, id DESC) index directly. + where.push(`(e.occurred_at, e.id) < (${push(cursor.occurredAt)}, ${push(cursor.id)}::uuid)`); + } + + const rows: Array = await this.dataSource.query( + `SELECT e.id, + e.wagon_id AS "wagonId", + e.wagon_number AS "wagonNumber", + e.event_type AS "type", + e.category, + e.occurred_at AS "occurredAt", + e.actor_user_id AS "actorUserId", + COALESCE(u.username, u.email) AS "actorName", + e.from_yard_id AS "fromYardId", + fy.label AS "fromYardLabel", + e.to_yard_id AS "toYardId", + ty.label AS "toYardLabel", + e.train_id AS "trainId", + t.code AS "trainCode", + e.train_schedule_id AS "trainScheduleId", + COALESCE(s.voyage_number, s.train_number) AS "scheduleLabel", + e.booking_id AS "bookingId", + b.reference AS "bookingReference", + e.from_value AS "fromValue", + e.to_value AS "toValue", + e.reason, + e.metadata + FROM freight.wagon_events e + LEFT JOIN iam.users u ON u.id = e.actor_user_id + LEFT JOIN freight.yards fy ON fy.id = e.from_yard_id + LEFT JOIN freight.yards ty ON ty.id = e.to_yard_id + LEFT JOIN freight.trains t ON t.id = e.train_id + LEFT JOIN freight.train_schedules s ON s.id = e.train_schedule_id + LEFT JOIN freight.bookings b ON b.id = e.booking_id + WHERE ${where.join(' AND ')} + ORDER BY e.occurred_at DESC, e.id DESC + LIMIT ${limit + 1}`, + params, + ); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page[page.length - 1]; + return { + items: page.map((r) => ({ + ...r, + occurredAt: new Date(r.occurredAt).toISOString(), + })), + nextCursor: hasMore && last ? encodeCursor(new Date(last.occurredAt), last.id) : null, + }; + } +} + +function encodeCursor(occurredAt: Date, id: string): string { + return Buffer.from(`${occurredAt.toISOString()}|${id}`, 'utf8').toString('base64url'); +} + +function decodeCursor(cursor?: string): { occurredAt: Date; id: string } | null { + if (!cursor) return null; + const raw = Buffer.from(cursor, 'base64url').toString('utf8'); + const sep = raw.indexOf('|'); + const occurredAt = sep > 0 ? new Date(raw.slice(0, sep)) : new Date(NaN); + const id = sep > 0 ? raw.slice(sep + 1) : ''; + if (Number.isNaN(occurredAt.getTime()) || !/^[0-9a-f-]{36}$/i.test(id)) { + throw new BadRequestException('Invalid history cursor'); + } + return { occurredAt, id }; +} diff --git a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts index ab86cea51..d86f65367 100644 --- a/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/purge-guard.spec.ts @@ -14,7 +14,12 @@ const makeService = (wagon: any, counts: [number, number, number], pinned = fals return []; }), }; - const svc = new WagonsService(wagonRepo as any, {} as any, dataSource as any); + const svc = new WagonsService( + wagonRepo as any, + {} as any, + dataSource as any, + { record: jest.fn() } as any, + ); return { svc, wagonRepo }; }; @@ -54,7 +59,12 @@ describe('WagonsService.purge', () => { it('404s an unknown wagon', async () => { const wagonRepo = { findOne: jest.fn().mockResolvedValue(null), remove: jest.fn() }; - const svc = new WagonsService(wagonRepo as any, {} as any, { query: jest.fn() } as any); + const svc = new WagonsService( + wagonRepo as any, + {} as any, + { query: jest.fn() } as any, + { record: jest.fn() } as any, + ); await expect(svc.purge('nope')).rejects.toThrow(NotFoundException); expect(wagonRepo.remove).not.toHaveBeenCalled(); }); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index e4492e5cb..89c2fd9f7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -27,6 +27,8 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; +import { WagonHistoryQueryDto } from '../wagon-history/dto/wagon-history-query.dto'; +import { WagonHistoryService } from '../wagon-history/wagon-history.service'; @ApiTags('wagons') // No class-level guard: reads (list, by-id, movements) are login-only reference @@ -34,13 +36,16 @@ import { WagonsService } from './wagons.service'; // fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage(). @Controller('wagons') export class WagonsController { - constructor(private readonly wagonsService: WagonsService) {} + constructor( + private readonly wagonsService: WagonsService, + private readonly wagonHistory: WagonHistoryService, + ) {} @Post() @FleetManage(FREIGHT_PERMS.wagons.create) @ApiOperation({ summary: 'Create a new wagon' }) - create(@Body() dto: CreateWagonDto) { - return this.wagonsService.create(dto); + create(@Body() dto: CreateWagonDto, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.create(dto, user?.id); } @Get() @@ -68,11 +73,26 @@ export class WagonsController { return this.wagonsService.listMovements(id); } + @Get(':id/history') + @FleetView(FREIGHT_PERMS.wagons.view) + @ApiOperation({ + summary: + 'Unified wagon history — yard moves, coupling, schedule pins/dispatch, status flips, cargo, lifecycle — newest first, keyset-paginated (`cursor`)', + }) + history(@Param('id', ParseUUIDPipe) id: string, @Query() query: WagonHistoryQueryDto) { + // No existence check on purpose: a deleted or purged wagon keeps its history. + return this.wagonHistory.list(id, query); + } + @Patch(':id') @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Update a wagon' }) - update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { - return this.wagonsService.update(id, dto); + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateWagonDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.wagonsService.update(id, dto, user?.id); } // Declared before @Delete(':id') so "permanent" is never captured as an id. @@ -86,29 +106,33 @@ export class WagonsController { summary: 'Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)', }) - purge(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.purge(id); + purge(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.purge(id, user?.id); } @Delete(':id') @FleetManage(FREIGHT_PERMS.wagons.delete) @ApiOperation({ summary: 'Delete a wagon' }) - remove(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.remove(id); + remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.remove(id, user?.id); } @Post(':id/assign-train') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Assign wagon to a train' }) - assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { - return this.wagonsService.assignToTrain(id, dto); + assignToTrain( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AssignWagonToTrainDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.wagonsService.assignToTrain(id, dto, user?.id); } @Post(':id/unassign-train') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Unassign wagon from train' }) - unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { - return this.wagonsService.unassignFromTrain(id); + unassignFromTrain(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.unassignFromTrain(id, user?.id); } @Post('bulk-transfer') diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 1ae1093f2..f15722b67 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,4 +1,10 @@ -import { Freight, PaginatedResponse, WagonMovementKind, WagonStatus } from '@edr/types'; +import { + Freight, + PaginatedResponse, + WagonEventType, + WagonMovementKind, + WagonStatus, +} from '@edr/types'; import { BadRequestException, Injectable, @@ -19,6 +25,16 @@ import { WagonStatusLog } from './entities/wagon-status-log.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; +import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service'; + +/** Wagon columns whose manual edits are diffed into a DETAILS_UPDATED history row. */ +const TRACKED_DETAIL_FIELDS = [ + 'wagonNumber', + 'wagonTypeId', + 'exportTrainNumber', + 'importTrainNumber', + 'notes', +] as const; @Injectable() export class WagonsService { @@ -28,9 +44,10 @@ export class WagonsService { @InjectRepository(Train) private readonly trainRepo: Repository, private readonly dataSource: DataSource, + private readonly wagonHistory: WagonHistoryService, ) {} - async create(dto: CreateWagonDto): Promise { + async create(dto: CreateWagonDto, userId?: string | null): Promise { const wagon = this.wagonRepo.create({ ...dto, status: dto.status ?? WagonStatus.Available, @@ -41,7 +58,22 @@ export class WagonsService { if (dto.currentYardId === undefined) wagon.currentYardId = null; if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: saved.id, + wagonNumber: saved.wagonNumber, + type: WagonEventType.Registered, + actorUserId: userId ?? null, + toYardId: saved.currentYardId ?? null, + trainId: saved.trainId ?? null, + toValue: saved.status, + metadata: { + wagonTypeId: saved.wagonTypeId, + exportTrainNumber: saved.exportTrainNumber ?? null, + importTrainNumber: saved.importTrainNumber ?? null, + }, + }); + return saved; } /** Shared filter/sort builder behind `findAll` (array) and `findAllPaged` (envelope). */ @@ -210,6 +242,10 @@ export class WagonsService { } } const previousYardId = wagon.currentYardId ?? null; + const previousStatus = wagon.status; + const before = Object.fromEntries( + TRACKED_DETAIL_FIELDS.map((f) => [f, (wagon as unknown as Record)[f] ?? null]), + ); Object.assign(wagon, dto); // `findById` eager-loads `currentYard`; when the DTO changes the scalar FK // TypeORM otherwise re-derives `current_yard_id` from the STALE relation @@ -243,6 +279,47 @@ export class WagonsService { }), ); } + // History: one row per kind of change — a yard move, a status flip, and + // the remaining field edits as a single diff. + const events: WagonEventInput[] = []; + const changes: Record = {}; + for (const f of TRACKED_DETAIL_FIELDS) { + if (dto[f] === undefined) continue; + const to = (wagon as unknown as Record)[f] ?? null; + if (before[f] !== to) changes[f] = { from: before[f], to }; + } + if (Object.keys(changes).length) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.DetailsUpdated, + actorUserId: userId ?? null, + metadata: { changes }, + }); + } + if (dto.currentYardId !== undefined && dto.currentYardId !== previousYardId) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: previousYardId, + toYardId: dto.currentYardId ?? null, + reason: 'Wagon record edited', + }); + } + if (dto.status !== undefined && dto.status !== previousStatus) { + events.push({ + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.StatusChanged, + actorUserId: userId ?? null, + fromValue: previousStatus, + toValue: dto.status, + reason: 'Wagon record edited', + }); + } + await this.wagonHistory.record(null, events); // Re-read with the relation so the response reflects the new yard label // instead of the stale relation object loaded before the assign. return this.findById(id); @@ -258,7 +335,7 @@ export class WagonsService { }); } - async remove(id: string): Promise { + async remove(id: string, userId?: string | null): Promise { const wagon = await this.findById(id); // A coupled wagon must be detached via train-builder before it can be // removed, so a built train never silently loses a wagon. @@ -275,6 +352,14 @@ export class WagonsService { // Soft delete (deleted_at) — hard-deleting would strand ledger/schedule // history that references this wagon. await this.wagonRepo.softRemove(wagon); + await this.wagonHistory.record(null, { + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Deleted, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + fromValue: wagon.status, + }); } /** @@ -288,7 +373,7 @@ export class WagonsService { * * Soft-deleted wagons are purgeable, so `withDeleted` is used to find them. */ - async purge(id: string): Promise { + async purge(id: string, userId?: string | null): Promise { const wagon = await this.wagonRepo.findOne({ where: { id }, withDeleted: true, @@ -343,6 +428,16 @@ export class WagonsService { ); } + // Recorded BEFORE the row goes: wagon_events has no FK, so the history of + // a purged wagon survives under its id and number snapshot. + await this.wagonHistory.record(null, { + wagonId: id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.Purged, + actorUserId: userId ?? null, + fromYardId: wagon.currentYardId ?? null, + fromValue: wagon.status, + }); await this.wagonRepo.remove(wagon); } @@ -366,7 +461,11 @@ export class WagonsService { return rows.length > 0; } - async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { + async assignToTrain( + wagonId: string, + dto: AssignWagonToTrainDto, + userId?: string | null, + ): Promise { const wagon = await this.findById(wagonId); // Mirror train-builder attachWagons: only a truly free, available wagon // (any yard) can be coupled, and never onto a dispatched train. @@ -399,13 +498,25 @@ export class WagonsService { ); } + const previousStatus = wagon.status; wagon.trainId = train.id; wagon.sequenceNumber = nextSequence; wagon.status = WagonStatus.Assigned; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.CoupledToTrain, + actorUserId: userId ?? null, + trainId: train.id, + fromYardId: wagon.currentYardId ?? null, + toValue: nextSequence, + metadata: { status: { from: previousStatus, to: WagonStatus.Assigned }, trainCode: train.code }, + }); + return saved; } - async unassignFromTrain(wagonId: string): Promise { + async unassignFromTrain(wagonId: string, userId?: string | null): Promise { const wagon = await this.findById(wagonId); // A wagon pinned to a live schedule is still operationally committed even // if the fleet train is being edited — don't free it out from under it. @@ -414,10 +525,24 @@ export class WagonsService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be detached`, ); } + const previousTrainId = wagon.trainId; + const previousSequence = wagon.sequenceNumber; + const previousStatus = wagon.status; wagon.trainId = null; wagon.sequenceNumber = null; wagon.status = WagonStatus.Available; - return this.wagonRepo.save(wagon); + const saved = await this.wagonRepo.save(wagon); + await this.wagonHistory.record(null, { + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.UncoupledFromTrain, + actorUserId: userId ?? null, + trainId: previousTrainId, + fromYardId: wagon.currentYardId ?? null, + fromValue: previousSequence, + metadata: { status: { from: previousStatus, to: WagonStatus.Available } }, + }); + return saved; } /** @@ -464,9 +589,20 @@ export class WagonsService { } let moved = 0; + const events: WagonEventInput[] = []; for (const wagon of wagons) { const previousYardId = wagon.currentYardId ?? null; if (previousYardId === toYardId) continue; + events.push({ + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + type: WagonEventType.MovedManually, + actorUserId: userId ?? null, + fromYardId: previousYardId, + toYardId, + reason: opts?.transferRequestId ? 'Transfer request fulfilled' : 'Bulk transfer', + metadata: opts?.transferRequestId ? { transferRequestId: opts.transferRequestId } : null, + }); wagon.currentYardId = toYardId; // Drop the eager relation so the scalar FK wins on save (see `update`). wagon.currentYard = null; @@ -484,6 +620,7 @@ export class WagonsService { ); moved++; } + await this.wagonHistory.record(queryRunner.manager, events); await queryRunner.commitTransaction(); return { moved }; @@ -547,6 +684,18 @@ export class WagonsService { } await queryRunner.manager.save(Wagon, wagons); if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs); + await this.wagonHistory.record( + queryRunner.manager, + logs.map((l) => ({ + wagonId: l.wagonId, + wagonNumber: wagons.find((w) => w.id === l.wagonId)?.wagonNumber ?? null, + type: WagonEventType.StatusChanged, + actorUserId: changedByUserId ?? null, + fromValue: l.fromStatus, + toValue: l.toStatus, + reason: dto.note ?? null, + })), + ); await queryRunner.commitTransaction(); return { updated: wagons.length }; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 8dd0681ce..85417ea87 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -1,6 +1,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; +import { + ArrayMaxSize, + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsBoolean, + IsIn, + IsNumber, + IsOptional, + IsString, + IsUUID, + Matches, + Min, +} from 'class-validator'; import { ValidateNested } from 'class-validator'; export class TruckEntranceDto { @@ -187,6 +200,22 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + /** + * The physical containers delivered by this truck. Container exports are + * received one truck at a time: either one 40ft box or up to two 20ft boxes. + */ + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + @ApiPropertyOptional({ type: TruckEntranceDto }) @IsOptional() @ValidateNested() diff --git a/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts new file mode 100644 index 000000000..fe9adb389 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/train-loading-window-gate.spec.ts @@ -0,0 +1,77 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; +import type { TrainLoadableItemRow } from './warehouse-inventory.service'; + +/** + * Cargo may only go onto a wagon inside a STARTED loading window at its + * boarding yard — the same rule the train schedule's own Load button enforces + * (assertStationWorkStarted). The warehouse loading queues load through a + * different service, so the rule is mirrored here; without it the two surfaces + * disagree and the queue offers a Load the schedule would refuse. + * + * Only the DataSource is touched, so the instance is built off the prototype + * rather than stubbing every collaborator. + */ +const row = (over: Partial = {}): TrainLoadableItemRow => + ({ + id: 'inv-1', + bookingId: 'b-1', + bookingReference: 'BK-1', + customerName: 'Acme', + containerNumber: 'CN-1', + cargoType: 'General', + weight: 20, + grnNumber: 'GRN-1', + inspectionStatus: 'PASSED', + status: 'READY_FOR_LOADING', + wagonId: 'w-1', + wagonNumber: 'W-001', + sequenceNo: 1, + originYardId: 'yard-1', + originYardLabel: 'Modjo', + loadingWindowStarted: true, + loadable: true, + ...over, + }) as TrainLoadableItemRow; + +function makeService(items: TrainLoadableItemRow[]) { + const query = jest.fn().mockResolvedValue([ + { trainNumber: 'T-100', origin: 'Modjo', destination: 'Djibouti', departure: null }, + ]); + const load = jest.fn().mockResolvedValue(undefined); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + service.load = load; + service.trainLoadableItems = jest.fn().mockResolvedValue(items); + return { service: service as unknown as WarehouseInventoryService, load }; +} + +describe('loadItemsOntoTrain() — station loading window gate', () => { + it('skips an item whose boarding yard has no started loading window', async () => { + const { service, load } = makeService([row({ loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).not.toHaveBeenCalled(); + expect(result.loadedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.results[0].reason).toContain('Start loading at Modjo first'); + }); + + it('loads once the window is started', async () => { + const { service, load } = makeService([row()]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(load).toHaveBeenCalledTimes(1); + expect(result.loadedCount).toBe(1); + expect(result.skippedCount).toBe(0); + }); + + it('still reports the wagon blocker first — the window is not the only gate', async () => { + const { service } = makeService([row({ wagonId: null, loadingWindowStarted: false })]); + + const result = await service.loadItemsOntoTrain('sched-1', ['inv-1']); + + expect(result.results[0].reason).toContain('No wagon allocated'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 15a3f6c4a..77755fa2f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -199,9 +199,14 @@ export class WarehouseInventoryController { @Get('loadable-trains') @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) - @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) - loadableTrains() { - return this.inventoryService.loadableTrains(); + @ApiOperation({ + summary: + 'EXPORT trains with inventory waiting to be loaded — pre-dispatch by default; `includeDispatched=true` adds rolling trains still picking cargo up along the corridor', + }) + loadableTrains(@Query('includeDispatched') includeDispatched?: string) { + return this.inventoryService.loadableTrains({ + includeDispatched: includeDispatched === 'true' || includeDispatched === '1', + }); } @Get('train/:scheduleId/loadable-items') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index aa032c17d..ef68f3673 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -17,8 +17,10 @@ import { import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { generateGrnNumber } from '../../common/grn.util'; +import { assertTruckLoad } from '../../common/truck-load.util'; import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { Booking } from '../bookings/entities/booking.entity'; +import type { StationWorkLog } from '../train-schedules/entities/train-schedule.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; import { Company } from '../companies/entities/company.entity'; import { Container } from '../container-management/entities/container.entity'; @@ -127,6 +129,7 @@ interface BookingSummaryRow { id: string; reference: string | null; status: string | null; + paymentStatus: string | null; customer: string | null; } @@ -274,12 +277,30 @@ export interface EligibleBookingRow { customerTruckType: string | null; customerTruckContainerNumber: string | null; customerTruckAssignedAt: string | null; + containerUnits: Array<{ + containerNumber: string; + containerSize: string | null; + weightTons: number; + received: boolean; + grnNumber: string | null; + }>; + receivedContainerCount: number; + remainingContainerCount: number; } export interface BulkReceiveResult { receivedCount: number; skippedCount: number; - results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[]; + results: { + bookingId: string; + status: string; + inventoryId?: string; + inventoryIds?: string[]; + grnNumber?: string; + receivedContainers?: number; + remainingContainers?: number; + reason?: string; + }[]; } @@ -319,6 +340,14 @@ export interface LoadableTrainRow { destination: string | null; status: string; departureTime: string | Date | null; + /** freight.yards.id the train departs from — the default boarding yard. */ + originStationId: string | null; + /** + * The schedule's per-yard loading/unloading time windows, exactly as the train + * schedule page stores them. The warehouse loading queues render the same + * Start/End controls off this, so both surfaces show one truth. + */ + stationWorkLogs: Record | null; /** Received/ready inventory not yet loaded onto this train. */ readyCount: number; /** Inventory already loaded onto this train. */ @@ -340,7 +369,12 @@ export interface TrainLoadableItemRow { wagonId: string | null; wagonNumber: string | null; sequenceNo: number | null; - /** True only when the item is READY_FOR_LOADING and has an allocated wagon. */ + /** The booking's boarding yard — the yard whose loading window gates this item. */ + originYardId: string | null; + originYardLabel: string | null; + /** True once "Start loading" was clicked for this item's boarding yard on this train. */ + loadingWindowStarted: boolean; + /** True only when the item is READY_FOR_LOADING, has an allocated wagon and GRN, and its yard's loading window is open. */ loadable: boolean; } @@ -1339,14 +1373,19 @@ export class WarehouseInventoryService { return this.findById(saved.id); } - /** Auto-load all READY_FOR_LOADING inventory whose booking is PAID. Unpaid stay pending. */ + /** + * Auto-load all READY_FOR_LOADING inventory whose booking is paid (payment + * status PAID — the booking status is not consulted). Unpaid stay pending. + */ async autoLoadReady(): Promise { const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); const result: AutoLoadResult = { loadedCount: 0, skippedCount: 0, results: [] }; for (const item of ready) { - const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; - if (bookingStatus !== 'PAID') { + const paymentStatus = item.bookingId + ? await this.getBookingPaymentStatus(item.bookingId) + : null; + if (paymentStatus !== 'PAID') { result.skippedCount += 1; result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason: 'Booking not PAID' }); continue; @@ -1392,6 +1431,9 @@ export class WarehouseInventoryService { ${companyNotifyPhoneExpr('company')} AS "customerPhone", COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", bcu.seal_numbers AS "sealNumbers", + COALESCE(bcu.container_units, '[]'::json) AS "containerUnits", + COALESCE(bcu.received_count, 0)::int AS "receivedContainerCount", + COALESCE(bcu.remaining_count, 0)::int AS "remainingContainerCount", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", -- service_types.includes_last_mile/first_mile are NOT read here: every @@ -1443,7 +1485,6 @@ export class WarehouseInventoryService { LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, SUM(booking_container.quantity)::int AS container_quantity, @@ -1462,7 +1503,18 @@ export class WarehouseInventoryService { ) bc ON true LEFT JOIN LATERAL ( SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers, - string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers, + COUNT(*) FILTER (WHERE unit.received_to_port)::int AS received_count, + COUNT(*) FILTER (WHERE NOT unit.received_to_port)::int AS remaining_count, + json_agg( + json_build_object( + 'containerNumber', unit.container_number, + 'containerSize', line.container_size, + 'weightTons', unit.vgm_tons, + 'received', unit.received_to_port, + 'grnNumber', unit.grn_number + ) ORDER BY unit.container_number + ) AS container_units FROM freight.booking_container_units unit JOIN freight.booking_container line ON line.id = unit.booking_container_id AND line.deleted_at IS NULL @@ -1479,7 +1531,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' - AND inv.id IS NULL + AND ( + (b.freight_type = 'CONTAINER' AND COALESCE(bcu.remaining_count, 0) > 0) + OR + (b.freight_type <> 'CONTAINER' AND NOT EXISTS ( + SELECT 1 FROM freight.warehouse_inventory inv + WHERE inv.booking_id = b.id AND inv.deleted_at IS NULL + )) + ) -- Direct truck-to-train cargo never comes to the warehouse, so never -- offer it for receipt. AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN' @@ -1509,6 +1568,7 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + bookingId?: string | null; }; booking: { companyId?: string | null; @@ -1635,9 +1695,6 @@ export class WarehouseInventoryService { } } - const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); - if (existing) { skip('Already received'); continue; } - const containerQuantity = Number(booking.containerQuantity ?? 0); if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { skip('Container booking has no container quantity'); @@ -1645,62 +1702,256 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; + // Multi-truck self-haul is selected explicitly at the gate. The booking + // source contains comma-joined legacy summary fields, which must never + // replace the one physical truck the receiver selected. + if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) { + truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber; + truckEntrance.driverName = dto.truckEntrance.driverName; + truckEntrance.driverPhone = dto.truckEntrance.driverPhone; + truckEntrance.truckType = dto.truckEntrance.truckType; + } if (dto.direction === 'EXPORT') { this.assertTruckEntrance(truckEntrance); } + + type ReceiveContainerUnit = { + containerNumber: string; + containerSize: string | null; + weightTons: string | number; + sealNumber: string | null; + bookingContainerId: string; + containerTypeId: string | null; + received: boolean; + }; + let selectedUnits: ReceiveContainerUnit[] = []; + let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); + + if (booking.freightType === 'CONTAINER') { + if (dto.bookingIds.length !== 1) { + throw new BadRequestException( + 'Receive one container booking per arriving truck so its containers and documents stay separate', + ); + } + const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!selectedNumbers.length) { + throw new BadRequestException('Select the containers arriving on this truck'); + } + const allUnits: ReceiveContainerUnit[] = await manager.query( + `SELECT UPPER(bcu.container_number) AS "containerNumber", + bc.container_size AS "containerSize", + bcu.vgm_tons AS "weightTons", + bcu.seal_number AS "sealNumber", + bc.id AS "bookingContainerId", + bc.container_type_id AS "containerTypeId", + bcu.received_to_port AS received + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + FOR UPDATE OF bcu`, + [bookingId], + ); + assertTruckLoad({ + containers: selectedNumbers, + bookingContainers: allUnits.map((unit) => unit.containerNumber), + sizes: allUnits + .filter((unit) => selectedNumbers.includes(unit.containerNumber)) + .map((unit) => unit.containerSize ?? ''), + }); + selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); + if (selectedUnits.some((unit) => unit.received)) { + const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); + throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + } + + // If this is a customer-assigned truck, it may only deliver the boxes + // assigned to that plate. Manual/unassigned arrivals retain the same + // physical capacity validation but have no assignment list to check. + if (truckEntrance?.truckPlateNumber) { + const assigned: Array<{ containerNumber: string }> = await manager.query( + `SELECT UPPER(ctc.container_number) AS "containerNumber" + FROM freight.customer_truck_assignments cta + JOIN freight.customer_truck_containers ctc + ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL + WHERE cta.booking_id = $1 + AND UPPER(cta.plate_number) = UPPER($2) + AND cta.deleted_at IS NULL`, + [bookingId, truckEntrance.truckPlateNumber], + ); + if ( + assigned.length > 0 && + selectedNumbers.some( + (number) => !assigned.some((container) => container.containerNumber === number), + ) + ) { + throw new BadRequestException( + `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, + ); + } + } + + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT inv.grn_number) AS batches + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.grn_number IS NOT NULL + AND inv.deleted_at IS NULL`, + [bookingId], + ); + grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; + if (truckEntrance) { + truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); + truckEntrance.unitCount = selectedNumbers.length; + truckEntrance.netWeightKg = selectedUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, + ); + } + } else { + const existing = await manager + .getRepository(WarehouseInventory) + .findOne({ where: { bookingId } }); + if (existing) { + skip('Already received'); + continue; + } + } + + const receivedBefore = + booking.freightType === 'CONTAINER' + ? Number( + ( + await manager.query( + `SELECT COUNT(*) AS count + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.received_to_port = true + AND bcu.deleted_at IS NULL`, + [bookingId], + ) + )[0]?.count ?? 0, + ) + : 0; + const receivedAfter = receivedBefore + selectedUnits.length; + const remainingAfter = Math.max(0, containerQuantity - receivedAfter); const receiveNote = this.buildReceiveNote({ grnNumber, direction: dto.direction, - notes: `Bulk received (${dto.direction})`, + notes: + booking.freightType === 'CONTAINER' + ? `${selectedUnits.length} container(s) arrived: ${selectedUnits + .map((unit) => unit.containerNumber) + .join(', ')}. ${remainingAfter} container(s) left.` + : `Bulk received (${dto.direction})`, truckEntrance, }); // Validate capacity before saving - const weight = Number(booking.weight) || 0; - const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + const weight = + booking.freightType === 'CONTAINER' + ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) + : Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); this.assertCapacity('Yard', yard, weight, 0, containerCount); this.assertCapacity('Zone', zone, weight, 0, containerCount); - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ - warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - bookingId, - quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight, - grnNumber, - status: 'RECEIVED', - arrivedAt: now, - notes: receiveNote, - }), - ); + const inventoryIds: string[] = []; + if (booking.freightType === 'CONTAINER') { + const containers = manager.getRepository(Container); + for (const unit of selectedUnits) { + let container = await containers.findOne({ + where: { containerNumber: unit.containerNumber }, + withDeleted: true, + }); + if (!container && !unit.containerTypeId) { + throw new BadRequestException( + `Container ${unit.containerNumber} has no container type and cannot be received`, + ); + } + if (!container) { + container = await containers.save( + containers.create({ + containerNumber: unit.containerNumber, + containerTypeId: unit.containerTypeId as string, + bookingContainerId: unit.bookingContainerId, + bookingId, + sealNumber: unit.sealNumber, + tareWeight: 0, + maxGrossWeight: Number(unit.weightTons || 0), + status: 'LOADED', + wagonId: null, + position: null, + wagonBookingAllocationId: null, + }), + ); + } else { + await containers.update(container.id, { + bookingId, + bookingContainerId: unit.bookingContainerId, + sealNumber: unit.sealNumber, + status: 'LOADED', + deletedAt: null, + }); + } + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + containerId: container.id, + quantity: 1, + weight: Number(unit.weightTons || 0), + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + grn_number = $3, + updated_at = NOW() + FROM freight.booking_container bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2::varchar[]) + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL`, + [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], + ); + } else { + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight, + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, + }), + ); + inventoryIds.push(saved.id); + } // Update warehouse/yard/zone capacity counters await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); - // Receiving the booking flags every container unit as received into the - // port (self-haul export: the delivering truck's goods are now in) so - // staff can raise the per-container GRN over what's received. - await manager.query( - `UPDATE freight.booking_container_units bcu - SET received_to_port = true, - received_at = COALESCE(bcu.received_at, NOW()), - updated_at = NOW() - FROM freight.booking_container bc - WHERE bc.id = bcu.booking_container_id - AND bc.booking_id = $1 - AND bc.deleted_at IS NULL - AND bcu.deleted_at IS NULL - AND bcu.received_to_port = false`, - [bookingId], - ); - // Export self-haul: this receive IS the truck's arrival — see // markCustomerTruckArrived / receive()'s single-booking mirror. if (dto.direction === 'EXPORT') { @@ -1710,7 +1961,7 @@ export class WarehouseInventoryService { await this.activityLog.record( { activityType: 'INVENTORY_RECEIVED', - inventoryId: saved.id, + inventoryId: inventoryIds[0], warehouseId: dto.warehouseId, description: truckEntrance?.truckPlateNumber ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` @@ -1730,13 +1981,26 @@ export class WarehouseInventoryService { grnNumber, direction: dto.direction, warehouseId: dto.warehouseId, + bookingId, }, booking, bookingId, }); result.receivedCount += 1; - result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber }); + result.results.push({ + bookingId, + status: 'RECEIVED', + inventoryId: inventoryIds[0], + inventoryIds, + grnNumber, + ...(booking.freightType === 'CONTAINER' + ? { + receivedContainers: receivedAfter, + remainingContainers: remainingAfter, + } + : {}), + }); } }); @@ -1827,7 +2091,17 @@ export class WarehouseInventoryService { */ private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE; - async loadableTrains(): Promise { + /** + * @param includeDispatched also list DISPATCHED trains. Loading follows the + * train after it rolls — a mid-corridor warehouse boards its cargo when the + * train stands at its yard — so the warehouse's train-centric loading view + * needs the same set the schedule workspace offers Load on. The default + * (pre-dispatch only) keeps the existing auto-load picker unchanged. + */ + async loadableTrains(opts: { includeDispatched?: boolean } = {}): Promise { + const statuses = opts.includeDispatched + ? ['DRAFT', 'SCHEDULED', 'DISPATCHED'] + : ['DRAFT', 'SCHEDULED']; const rows: Array< LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null } > = await this.dataSource.query( @@ -1840,6 +2114,8 @@ export class WarehouseInventoryService { dy.country AS "destinationCountry", ts.status AS "status", ts.scheduled_departure_date AS "departureTime", + ts.origin_station_id AS "originStationId", + ts.station_work_logs AS "stationWorkLogs", (SELECT count(*) FROM sched_bookings sb JOIN freight.warehouse_inventory inv ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL @@ -1863,7 +2139,7 @@ export class WarehouseInventoryService { AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED') ) ORDER BY ts.scheduled_departure_date ASC NULLS LAST`, - [['DRAFT', 'SCHEDULED']], + [statuses], ); return rows @@ -1903,7 +2179,15 @@ export class WarehouseInventoryService { inv.status AS "status", wl.wagon_id AS "wagonId", wl.wagon_number AS "wagonNumber", - wl.sequence_no AS "sequenceNo" + wl.sequence_no AS "sequenceNo", + COALESCE(b.origin_yard_id, ts.origin_station_id) AS "originYardId", + COALESCE(oy.label, oy.code) AS "originYardLabel", + -- Same rule the train schedule's own Load button obeys + -- (assertStationWorkStarted): the yard's loading window must have + -- been started before its cargo may go on a wagon. + (ts.station_work_logs #>> ARRAY[ + COALESCE(b.origin_yard_id, ts.origin_station_id)::text, 'loading', 'startedAt' + ]) IS NOT NULL AS "loadingWindowStarted" FROM sched_bookings sb JOIN freight.train_schedules ts ON ts.id = sb.schedule_id JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL @@ -1911,6 +2195,7 @@ export class WarehouseInventoryService { LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.yards oy ON oy.id = COALESCE(b.origin_yard_id, ts.origin_station_id) LEFT JOIN LATERAL ( SELECT w.id AS wagon_id, w.wagon_number, tsw.sequence_no FROM freight.wagon_booking_allocations wba @@ -1933,9 +2218,14 @@ export class WarehouseInventoryService { ...r, // Export flow: received at the warehouse -> GRN -> loaded onto its wagon. // The row only exists once the goods were received, so requiring a GRN and - // an allocated wagon completes the chain. + // an allocated wagon completes the chain. The yard's loading window is the + // fourth link — the warehouse queue must not offer what the train + // schedule's own Load button would refuse. loadable: - r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber), + r.status === 'READY_FOR_LOADING' && + Boolean(r.wagonId) && + Boolean(r.grnNumber) && + r.loadingWindowStarted, })); } @@ -1992,6 +2282,11 @@ export class WarehouseInventoryService { // nothing rides a train without one. if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; } if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; } + // Mirrors assertStationWorkStarted on the train-schedule load path. + if (!item.loadingWindowStarted) { + skip(`Start loading at ${item.originYardLabel ?? 'the boarding yard'} first — the loading time window has not been started`); + continue; + } try { await this.load(inventoryId, { @@ -3107,6 +3402,7 @@ export class WarehouseInventoryService { grnNumber, direction: bookingDirection, warehouseId: dto.warehouseId, + bookingId: dto.bookingId ?? null, }); return saved.id; @@ -3491,12 +3787,14 @@ export class WarehouseInventoryService { throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); } - const status = await this.getBookingStatus(dto.bookingId); - if (!status) { + const paymentStatus = await this.getBookingPaymentStatus(dto.bookingId); + if (paymentStatus === null) { throw new NotFoundException(`Booking ${dto.bookingId} not found`); } - if (status !== 'PAID') { - throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); + if (paymentStatus !== 'PAID') { + throw new BadRequestException( + `Booking must be paid to reserve inventory (payment status: ${paymentStatus})`, + ); } await this.dataSource.transaction(async (manager) => { @@ -3988,7 +4286,7 @@ export class WarehouseInventoryService { `SELECT inv.id, inv.release_date AS "releaseDate", inv.release_order_reference AS "releaseOrderReference", - inv.quantity, + COALESCE(receipt_batch.quantity, inv.quantity) AS quantity, inv.weight, inv.status, inv.notes, @@ -4317,11 +4615,12 @@ export class WarehouseInventoryService { */ async bookingContainerWeights( bookingId: string, - ): Promise> { - const rows: Array<{ containerNumber: string; weightTons: string }> = + ): Promise> { + const rows: Array<{ containerNumber: string; weightTons: string; containerSize: string | null }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", - MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons" + MAX(COALESCE(bcu.vgm_tons, 0)) AS "weightTons", + MAX(bc.container_size) AS "containerSize" FROM freight.booking_container_units bcu JOIN freight.booking_container bc ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL @@ -4333,6 +4632,7 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, weightTons: Number(r.weightTons) || 0, + containerSize: r.containerSize ?? null, })); } @@ -4534,7 +4834,7 @@ export class WarehouseInventoryService { -- An unweighed item still reports the cargo weight it holds: fall -- back to the item's container VGM, then the booking's declared -- weight, so a GRN never prints "0 t" for goods that are present. - COALESCE(NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, + COALESCE(NULLIF(receipt_batch.weight, 0), NULLIF(inv.weight, 0), item_vgm.tons, b.cargo_total_weight_vgm, 0) AS weight, inv.volume, inv.status, inv.notes, @@ -4551,8 +4851,8 @@ export class WarehouseInventoryService { origin_yard.code AS "originYardCode", destination_yard.label AS "destinationYardLabel", destination_yard.code AS "destinationYardCode", - COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", - booking_container."containerSummary" AS "bookingContainerSummary", + COALESCE(receipt_batch.container_numbers, container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(receipt_batch.container_summary, booking_container."containerSummary") AS "bookingContainerSummary", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -4582,6 +4882,40 @@ export class WarehouseInventoryService { WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL ) booking_container ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*)::int AS quantity, + SUM(batch.weight) AS weight, + string_agg(batch.container_number, ', ' ORDER BY batch.container_number) + FILTER (WHERE batch.container_number IS NOT NULL) AS container_numbers, + string_agg( + CONCAT(batch.container_number, ' (', COALESCE(batch.container_size, 'size unknown'), ')'), + ', ' ORDER BY batch.container_number + ) FILTER (WHERE batch.container_number IS NOT NULL) AS container_summary + FROM ( + SELECT inv2.id, + inv2.weight, + c2.container_number, + bc2.container_size + FROM freight.warehouse_inventory inv2 + LEFT JOIN freight.containers c2 + ON c2.id = inv2.container_id AND c2.deleted_at IS NULL + LEFT JOIN freight.booking_container_units bcu2 + ON bcu2.container_number = c2.container_number AND bcu2.deleted_at IS NULL + LEFT JOIN freight.booking_container bc2 + ON bc2.id = bcu2.booking_container_id + AND bc2.booking_id = inv2.booking_id + AND bc2.deleted_at IS NULL + WHERE inv2.booking_id = inv.booking_id + AND inv2.deleted_at IS NULL + AND COALESCE( + NULLIF(TRIM(inv2.grn_number), ''), + substring(inv2.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) = COALESCE( + NULLIF(TRIM(inv.grn_number), ''), + substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') + ) + ) batch + ) receipt_batch ON true LEFT JOIN LATERAL ( SELECT SUM(bcu.vgm_tons) AS tons FROM freight.booking_container_units bcu @@ -6614,10 +6948,9 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + /** Resolves the company, which unlocks in-app + email alongside the SMS. */ + bookingId?: string | null; }): Promise { - const phone = params.phone?.trim(); - if (!phone) return; - const ownerName = params.ownerName?.trim() || 'Customer'; const bookingReference = params.bookingReference?.trim(); const message = @@ -6627,6 +6960,47 @@ export class WarehouseInventoryService { (params.direction ? `Direction: ${params.direction}. ` : '') + `Thank you.`; + // A booking gives us the company, and with it the customer's inbox and + // email — not just whatever phone number the gate clerk typed. Without one + // (manual or backlog receive) the typed phone is all there is, so the + // original SMS-only path stands. + let companyId: string | null = null; + if (params.bookingId) { + try { + const [row]: Array<{ companyId: string | null }> = await this.dataSource.query( + `SELECT company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [params.bookingId], + ); + companyId = row?.companyId ?? null; + } catch (error) { + this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`); + } + } + + if (companyId) { + try { + await this.inbox.notify({ + recipients: { companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Cargo received — GRN issued', + body: message, + link: params.bookingId ? `/bookings/${params.bookingId}` : undefined, + data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null }, + }); + // Sends SMS *and* email to the company's own contacts, so the typed + // phone below is skipped to avoid texting the customer twice. + await sendCompanyChannels(this.dataSource, this.notifications, companyId, message); + return; + } catch (error) { + this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`); + } + } + + const phone = params.phone?.trim(); + if (!phone) return; try { await this.notifications.directSend('sms', phone, message); } catch (error) { @@ -7112,12 +7486,19 @@ export class WarehouseInventoryService { }; } - private async getBookingStatus(bookingId: string): Promise { - const [row]: Array<{ status: string | null }> = await this.dataSource.query( - 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + /** + * The booking's PAYMENT status — the only signal loading/reservation gates + * use to decide "paid". Returns null when the booking does not exist; + * an existing booking with no payment status yet reads as PENDING. + */ + private async getBookingPaymentStatus(bookingId: string): Promise { + const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query( + `SELECT payment_status AS "paymentStatus" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`, [bookingId], ); - return row?.status ?? null; + if (!row) return null; + return row.paymentStatus ?? 'PENDING'; } private async attachBookingSummaries(items: WarehouseInventory[]): Promise { @@ -7125,7 +7506,8 @@ export class WarehouseInventoryService { if (bookingIds.length === 0) return; const rows: BookingSummaryRow[] = await this.dataSource.query( - `SELECT b.id, b.reference, b.status, company.name AS customer + `SELECT b.id, b.reference, b.status, b.payment_status AS "paymentStatus", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.id = ANY($1) AND b.deleted_at IS NULL`, @@ -7139,6 +7521,7 @@ export class WarehouseInventoryService { Object.assign(item, { bookingReference: summary.reference, bookingStatus: summary.status, + bookingPaymentStatus: summary.paymentStatus, customerName: summary.customer, }); }); diff --git a/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts new file mode 100644 index 000000000..25ae2f038 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-mor-test-buyers.ts @@ -0,0 +1,100 @@ +import { AppDataSource } from "../data-source"; + +/** + * Seeds the 30 test taxpayers the Ministry of Revenues issued for the EIMS/BSP + * **non-self buyer** certification run — the checklist item that needs a real + * invoice filed against a buyer that is not EDR itself. + * + * Every field comes from MoR's own roster, except the two location codes, which + * do not survive contact with the Ministry's own location master: + * + * - MoR's sheet gives `Region = "1"`. `PARISH_NO 1` is not an Ethiopian region + * at all (it is Djibouti), so the region is stored by name — `Bole` is an + * Addis Ababa sub-city, which fixes the region unambiguously (PARISH_NO 13). + * - MoR's sheet gives `City = "101"`, which is GOFA ZONE in SNNPRS. The buyer + * is in Bole, so the sub-city is stored by name (CITY_NO 78). + * + * In other words MoR does not validate the geographic codes it sends itself; + * these rows carry the addresses that actually resolve. The roster carries no + * woreda, so every row takes `NO WOREDA-144` — MoR's *own* "not specified" + * locality under BOLE (LOCALITY_NO 574), the same code EDR's static seller + * details already file under. Nothing here is invented. + * + * Idempotent: `ON CONFLICT (tin) DO NOTHING`. TIN 0089238373 is already on file + * as a real customer (Afri Software Solutions) and is deliberately left alone. + */ + +/** + * `[TIN, phone, legal name, email, kebele?, house number?]`, verbatim from MoR's roster. The two + * trailing fields default to the values 22 of the 30 rows share. + */ +const KEBELE = "Near Bole Airport"; +const HOUSE_NO = "123B"; + +const MOR_TEST_BUYERS: Array<[string, string, string, string, string?, string?]> = [ + ["0089238373", "251911091245", "Taxpayer A", "codethicaet@gmail.com", "Near Airport", "101"], + ["0054864576", "251911091245", "Taxpayer B", "shehir8@gmail.com", "Near Airport"], + ["0049056594", "251911091245", "Taxpayer C", "teme@odooethiopia.com", "Near Airport"], + ["0059819904", "251911091245", "Taxpayer D", "rubiethoplc@gmail.com", "Near Airport"], + ["0000018932", "251911091245", "Taxpayer E", "amanuelephremedu@gmail.com", "Near Airport"], + ["0088683375", "251911091245", "Taxpayer F", "ermiastegegn576@gmail.com", "Near Airport"], + ["0068421445", "251911091245", "Taxpayer G", "qelemmeda@gmail.com", "Near Airport"], + ["0004404844", "251911091245", "Taxpayer H", "dagnegamu24@gmail.com"], + ["0000037187", "251911091245", "Taxpayer I", "deresr.belay@gmail.com"], + ["0050167460", "251911091245", "Taxpayer J", "sera2013ec@gmail.com"], + ["0079690836", "251909978781", "Taxpayer K", "asmeradefa@gmail.com"], + ["0068180813", "251944310004", "Taxpayer L", "hailelt@gmail.com"], + ["0083907363", "251944310004", "Taxpayer M", "dawitfissha1@gmail.com"], + ["0089032785", "251944310004", "Taxpayer N", "tewahido11@gmail.com"], + ["0003826418", "251944310004", "Taxpayer O", "alemayehu.t@marakisoft.com"], + ["0053374665", "251944310004", "Taxpayer P", "getlelaw@gmail.com"], + ["0016175194", "251911463482", "Taxpayer Q", "abiye.abi@gmail.com", "Near Airport"], + ["0094542975", "251911463482", "Taxpayer R", "abelgebreananya@gmail.com"], + // MoR's roster carries an 11-digit phone here; kept verbatim rather than "corrected". + ["0088514835", "25191124368", "Taxpayer S", "ewnget77@gmail.com"], + ["0076217301", "251960403750", "Taxpayer T", "merontamirat.redcloud@gmail.com"], + ["0003826419", "251911516507", "Taxpayer 322", "alemayehu.t@marakisoft.com"], + ["0056961577", "251929020729", "Taxpayer 323", "ltictsolution@gmail.com", undefined, "1234B"], + ["0090853345", "251911376145", "Taxpayer 324", "kidusgoshu2be@gmail.com"], + ["0000028643", "251988899003", "Taxpayer 325", "mesaysisay10@gmail.com"], + ["0057751727", "251911437928", "Taxpayer 326", "zewdugeta@gmail.com"], + ["0082549522", "251907256543", "Taxpayer 327", "brookgm2@gmail.com"], + ["0093283311", "251953915419", "Taxpayer 328", "henock.ad@gmail.com"], + ["0078795374", "251911091245", "Taxpayer 329", "danielltadesse@gmail.com"], + ["0093346931", "251935724920", "Taxpayer 330", "halidabd63@gmail.com"], + ["0040887091", "251913792959", "Taxpayer 331", "milextech@gmail.com"], +]; + +async function seedMorTestBuyers(): Promise { + await AppDataSource.initialize(); + try { + for (const [tin, phone, name, email, kebele, houseNo] of MOR_TEST_BUYERS) { + await AppDataSource.query( + `INSERT INTO freight.companies + (name, type, kind, status, tin, country, region, zone, woreda, kebele, + house_no, phone, email) + VALUES ($1, 'customer', 'commercial', 'active', $2, 'Ethiopia', 'Addis Ababa', 'Bole', + 'NO WOREDA-144', $3, $4, $5, $6) + ON CONFLICT (tin) DO NOTHING`, + [name, tin, kebele ?? KEBELE, houseNo ?? HOUSE_NO, phone, email], + ); + } + + const summary = await AppDataSource.query( + `SELECT count(*)::int AS on_file, + count(*) FILTER (WHERE name LIKE 'Taxpayer %')::int AS seeded + FROM freight.companies + WHERE tin = ANY($1::text[])`, + [MOR_TEST_BUYERS.map(([tin]) => tin)], + ); + console.table(summary); + console.log("Seeded MoR EIMS/BSP test buyers."); + } finally { + await AppDataSource.destroy(); + } +} + +seedMorTestBuyers().catch((err) => { + console.error("MoR test buyer seed failed:", err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index bb83518df..0a3d26d71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:contracts:suspend", "Suspend / resume a signed contract", ), + // Terminal kill switch. Unlike suspend this cannot be undone — the customer + // re-submits a fresh contract with the same parameters instead. + perm( + "a3000001-0001-4000-8000-00000000001c", + "edr_freight_app:contracts:cancel", + "Cancel a contract (terminal)", + ), ]; // Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and @@ -652,6 +659,32 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// C3. Transit assignments — a transit agent's work on one booking: status, +// timings and documents. Separate from the booking's transit-assignee handshake, +// which only decides who will handle customs. +export const TRANSIT_ASSIGNMENT_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "d1a00003-0001-4000-8000-000000000001", + "edr_freight_app:transit_assignments:view", + "View transit assignments", + ), + perm( + "d1a00003-0001-4000-8000-000000000002", + "edr_freight_app:transit_assignments:create", + "Assign a transit agent to a booking", + ), + perm( + "d1a00003-0001-4000-8000-000000000003", + "edr_freight_app:transit_assignments:update", + "Update a transit assignment and its documents", + ), + perm( + "d1a00003-0001-4000-8000-000000000004", + "edr_freight_app:transit_assignments:delete", + "Remove a transit assignment", + ), +]; + // Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1457,6 +1490,22 @@ export const ADDITIONAL_CHARGE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// E''. Empty container return requests — customer asks to send empties back on +// a booking that was sold without the return service; operations price and +// approve it, the customer pays, then books the date and truck. +export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f2e00002-0001-4000-8000-000000000001", + "edr_freight_app:empty_return_requests:view", + "View empty container return requests", + ), + perm( + "f2e00002-0001-4000-8000-000000000002", + "edr_freight_app:empty_return_requests:review", + "Approve or reject an empty container return request", + ), +]; + // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1883,6 +1932,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:additional_charges:get_notification", "Receive additional charge notifications", ), + perm( + "f3a00001-0001-4000-8000-00000000000a", + "edr_freight_app:warehouse_inventory:get_notification", + "Receive warehouse desk notifications (containers left behind at loading)", + ), ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ @@ -1890,6 +1944,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...OVERVIEW_LAYOUT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, + ...TRANSIT_ASSIGNMENT_PERMISSIONS, ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, @@ -1898,6 +1953,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...WAREHOUSE_PERMISSIONS, ...PORT_TERMINAL_PERMISSIONS, ...ADDITIONAL_CHARGE_PERMISSIONS, + ...EMPTY_RETURN_REQUEST_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2034,6 +2090,7 @@ export const FREIGHT_PERMS = { clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", suspend: "edr_freight_app:contracts:suspend", + cancel: "edr_freight_app:contracts:cancel", editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", @@ -2118,6 +2175,12 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:customers:get_notification", }, + transitAssignments: { + view: "edr_freight_app:transit_assignments:view", + create: "edr_freight_app:transit_assignments:create", + update: "edr_freight_app:transit_assignments:update", + delete: "edr_freight_app:transit_assignments:delete", + }, shippingLines: { view: "edr_freight_app:shipping_lines:view", create: "edr_freight_app:shipping_lines:create", @@ -2342,6 +2405,12 @@ export const FREIGHT_PERMS = { release: "edr_freight_app:warehouse_inventory:release", deliver: "edr_freight_app:warehouse_inventory:deliver", inspect: "edr_freight_app:warehouse_inventory:inspect", + /** + * Notification selector, not a route guard — who gets pinged when cargo is + * left behind at loading and needs warehouse space. Assign it to whichever + * desk owns that; it grants access to nothing. + */ + getNotification: "edr_freight_app:warehouse_inventory:get_notification", }, interchangeDocuments: { view: "edr_freight_app:interchange_documents:view", @@ -2366,6 +2435,10 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:additional_charges:get_notification", }, + emptyReturnRequests: { + view: "edr_freight_app:empty_return_requests:view", + review: "edr_freight_app:empty_return_requests:review", + }, settings: { fileUpload: { view: "edr_freight_app:settings:file_upload:view", @@ -2779,6 +2852,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.clearanceReview, FREIGHT_PERMS.contracts.finalizeClearance, FREIGHT_PERMS.contracts.createBooking, + // GL rebooks cancelled-wagon credits on the customer's behalf — whoever + // cancelled (customer or staff) and whichever side was at fault. Needs to + // see the ledger rows and to redeem the credit. + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationRebook, FREIGHT_PERMS.contracts.clearanceEtActions, FREIGHT_PERMS.contracts.clearanceDutyAdvise, FREIGHT_PERMS.contracts.finalInvoiceConfirm, @@ -2832,6 +2910,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.generateContract, ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), FREIGHT_PERMS.contracts.suspend, + // Terminal kill switch, granted alongside suspend on the same desk that + // already rejects contracts and cancels bookings. + FREIGHT_PERMS.contracts.cancel, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, // Marketing follows up with the customer when a reviewer sends profile diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index e0ef813e9..7674f924c 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -97,6 +97,7 @@ "react-markdown": "^9.1.0", "react-pdf": "^10.4.1", "react-pdf-html": "^2.1.5", + "react-phone-number-input": "^3.4.17", "react-quill-new": "^3.8.3", "react-resizable-panels": "^3.0.6", "react-router-dom": "^6.27.0", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 539f2ee98..f95fe7cca 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage" import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; +import EmptyReturnRequestsPage from "./pages/warehouses/EmptyReturnRequestsPage"; import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; @@ -709,6 +710,19 @@ const App = () => { } /> + + + + } + /> { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, AUTH_USER_COOKIE, + POSITION_COOKIE, + // Pre-rename name, still cleared so a stale value cannot outlive logout. "current-position-id", "selected-position-id", ].forEach(clearCookie); diff --git a/apps/edr-freight-web/backoffice/src/components/PhoneField.test.ts b/apps/edr-freight-web/backoffice/src/components/PhoneField.test.ts new file mode 100644 index 000000000..b6576f946 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/PhoneField.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { isSmsReachable, isValidPhone } from "./PhoneField"; + +/** + * `isSmsReachable` mirrors `isDomesticPhone` in the API's otp.service. The two + * must agree: this one greys out the SMS option, that one decides whether the + * message is actually sent, and a disagreement means the UI promises a text + * nobody sends (or hides one that would have worked). These cases are the same + * ones the API spec asserts. + */ +describe("isSmsReachable", () => { + it.each(["+251986680099", "0986680099", "251986680099"])( + "accepts Ethiopian mobile form %s", + (phone) => expect(isSmsReachable(phone)).toBe(true), + ); + + it.each(["+25377123456", "25377123456", "77123456"])( + "accepts Djibouti mobile form %s", + (phone) => expect(isSmsReachable(phone)).toBe(true), + ); + + it.each([ + "+14155550123", + "+447911123456", + "0712345678", + "+2519866", + "12345", + // Djibouti fixed line — valid number, not a mobile the gateway serves. + "+25321350000", + "+25366123456", + ])("rejects unreachable or malformed %s", (phone) => + expect(isSmsReachable(phone)).toBe(false), + ); + + it.each([undefined, null, ""])("treats %s as unreachable", (phone) => + expect(isSmsReachable(phone)).toBe(false), + ); +}); + +/** + * The country-picker input emits a PARTIAL E.164 while the user is still + * typing — "+25377" is a non-empty string that will post happily and come back + * as a 400 from the API's own IsValidPhone. Forms must treat "non-empty" and + * "complete" as different questions, so this is the check they call. + */ +describe("isValidPhone", () => { + it.each(["+25377834567", "+251911223344"])( + "accepts the complete number %s", + (phone) => expect(isValidPhone(phone)).toBe(true), + ); + + it.each(["+253", "+25377", "+2537712", "+251", "+2519112"])( + "rejects the partial number %s the picker emits mid-typing", + (phone) => expect(isValidPhone(phone)).toBe(false), + ); + + it.each([undefined, null, ""])("treats %s as invalid", (phone) => + expect(isValidPhone(phone)).toBe(false), + ); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx b/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx new file mode 100644 index 000000000..8714ccd8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/PhoneField.tsx @@ -0,0 +1,107 @@ +import { Input, TextInput } from "@mantine/core"; +import RPNInput, { isValidPhoneNumber } from "react-phone-number-input"; +import "react-phone-number-input/style.css"; +import "./phone-field.css"; + +/** + * The countries the railway operates between, and the only two the SMS gateway + * is contracted to reach (see `REACHABLE_MOBILE_PATTERNS` in the API's + * otp.service). Restricting the picker to them keeps staff from entering a + * number that would validate but could never receive an activation link. + */ +export const SUPPORTED_PHONE_COUNTRIES = ["DJ", "ET"] as const; + +/** + * Djibouti — most accounts entered here (transit agents above all) are + * Djibouti-side, so it saves the picker interaction on the common case. + */ +export const DEFAULT_PHONE_COUNTRY = "DJ"; + +/** + * Re-exported so callers can validate before submitting. + * + * Needed because the input emits a PARTIAL E.164 while the user is still + * typing — "+25377" and "+2537712" are non-empty strings that reach a payload + * happily and then come back as a 400 from the API's own `IsValidPhone`. A + * caller must treat "non-empty" and "complete" as different questions. + */ +export const isValidPhone = (value?: string | null): boolean => + !!value && isValidPhoneNumber(value); + +/** + * Whether the SMS gateway can actually reach this number. + * + * Mirrors `isDomesticPhone` in the API's otp.service — Ethiopian `+2519…` and + * Djiboutian `+25377…` mobiles. Anything else (a landline, another country) is + * queued and silently lost, so the UI offers email instead of promising an SMS. + */ +export function isSmsReachable(rawPhone?: string | null): boolean { + if (!rawPhone) return false; + const digits = rawPhone.trim().replace(/[^\d+]/g, ""); + const bare = digits.replace(/^\+/, "").replace(/^0+/, ""); + const normalized = digits.startsWith("+") + ? digits + : /^251\d{9}$|^253\d{8}$/.test(digits) + ? `+${digits}` + : /^9\d{8}$|^7\d{8}$/.test(bare) + ? `+251${bare}` + : /^77\d{6}$/.test(bare) + ? `+253${bare}` + : digits; + return /^\+2519\d{8}$/.test(normalized) || /^\+25377\d{6}$/.test(normalized); +} + +export interface PhoneFieldProps { + label?: string; + value?: string; + onChange: (value: string | undefined) => void; + error?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; + description?: string; +} + +/** + * Phone input with a country selector, limited to Ethiopia and Djibouti. + * Emits a single E.164 value (e.g. +251912345678, +25377123456) so the API + * never has to guess a country from a bare local number. + */ +export function PhoneField({ + label, + value, + onChange, + error, + required, + disabled, + placeholder = "77 83 45 67", + description, +}: PhoneFieldProps) { + return ( + +
+ +
+
+ ); +} + +export default PhoneField; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx index dc7286b5c..f2d6c07d4 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCargoCard.tsx @@ -30,6 +30,13 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { ); const isBulk = booking.freightType === "BULK"; + // NUMBER_OF_WAGONS cargo is booked by a wagon COUNT, not by tonnage — the + // count the customer fixed is what allocation and per-wagon pricing use, so + // it belongs on the card next to the weight. + const requestedWagons = + isBulk && booking.cargoType?.unitOfMeasure === "NUMBER_OF_WAGONS" + ? Number(booking.bulkRequestedWagons ?? 0) || null + : null; // Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers: // the freight kind, with the shipper's own description alongside. const cargoHeadline = isBulk @@ -46,6 +53,11 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { {isBulk ? "Bulk" : "Container"} + {requestedWagons != null ? ( + + {requestedWagons} wagon{requestedWagons === 1 ? "" : "s"} + + ) : null} {cargoDescription ? ( — {cargoDescription} @@ -62,6 +74,9 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) { } /> + {requestedWagons != null && ( + + )} {items != null && } { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const tons = (value: number | string | null | undefined): string => + `${num(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} t`; + +/** Allocation status → badge colour. PLANNED is the pre-loading default. */ +const STATUS_COLORS: Record = { + PLANNED: "blue", + LOADED: "edr-green", + UNLOADED: "gray", + CANCELLED: "red", +}; + +/** + * The booking detail page's "Wagons" tab: every wagon allocated to this booking, + * with its containers or bulk load, plus an Excel export of the same list. + * + * A booking has no wagons until it is paid and placed on a train, so the empty + * state is the normal case for most of a booking's life — it explains the + * precondition rather than reading as an error. + */ +export function BookingWagonsPanel({ + bookingId, + bookingReference, +}: { + bookingId: string; + bookingReference: string; +}) { + const [exporting, setExporting] = useState(false); + + const { data, isLoading, isError } = useQuery( + api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }), + ); + + const wagons = useMemo(() => data ?? [], [data]); + + const totals = useMemo(() => { + const containerCount = wagons.reduce( + (sum, w) => sum + (w.containers?.length ?? 0), + 0, + ); + const allocated = wagons.reduce( + (sum, w) => sum + num(w.allocatedWeightTons), + 0, + ); + const capacity = wagons.reduce((sum, w) => sum + num(w.capacityTons), 0); + return { containerCount, allocated, capacity }; + }, [wagons]); + + // The train is a property of the allocation, so every wagon on this booking + // carries the same one — read it off the first row rather than per row. + const train = wagons[0]; + + const handleExport = async () => { + setExporting(true); + try { + const blob = await bookingsService.downloadWagonsWorkbook(bookingId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `wagons-${bookingReference}.xlsx`; + a.click(); + URL.revokeObjectURL(url); + } catch (error) { + // Blob response: the JSON reason is inside the Blob, so the sync path + // would surface only "Request failed with status code 400". + toast.error(await extractDownloadErrorMessage(error)); + } finally { + setExporting(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + } + loading={exporting} + // The sheet would be headers with no rows — nothing to hand over. + disabled={wagons.length === 0} + onClick={() => void handleExport()} + > + Export Excel + + } + > + {isError ? ( + + Could not load the wagon allocations for this booking. + + ) : wagons.length === 0 ? ( + + Wagons appear here once the booking is paid and allocated onto a train. + + ) : ( + + + + + + + + + {train?.departureAt ? ( + + + Departs {formatDate(train.departureAt)} + + {train.originStation && train.destinationStation ? ( + + · {train.originStation} → {train.destinationStation} + + ) : null} + + ) : null} + + + + + + Seq + Wagon + Type + Status + Allocated + Capacity + Load + + + + {wagons.map((w) => ( + + + + {w.sequenceNo ?? "—"} + + + + + {w.wagonNumber ?? "—"} + + + + {w.wagonType ?? "—"} + + + + {w.status} + + + + {tons(w.allocatedWeightTons)} + + + + {tons(w.capacityTons)} + + + + {w.containers?.length ? ( + + {w.containers.map((c, i) => ( + + + + {c.containerNumber ?? "—"} + {c.sizeFt ? ` · ${c.sizeFt}ft` : ""} + + + ))} + + ) : w.bulkCargoDescription || w.loadType === "BULK" ? ( + + {w.bulkCargoDescription ?? "Bulk"} + {w.bulkQuantity ? ` · ${num(w.bulkQuantity)}` : ""} + + ) : ( + + — + + )} + + + ))} + +
+
+
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 23f9b2bd8..d23b75f43 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -3,6 +3,7 @@ export * from "./SectionCard"; export * from "./ClearanceReviewSection"; export * from "./BookingDocumentsPanel"; export * from "./BookingTrucksPanel"; +export * from "./BookingWagonsPanel"; export * from "./ContractOrdersPanel"; export * from "./MetricTile"; export * from "./BookingDetailToolbar"; diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx new file mode 100644 index 000000000..318ce3f9b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -0,0 +1,407 @@ +import { useEffect, useMemo, useState } from "react"; +import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; +import { OperationDatePicker } from "@edr/ui-common"; + +import { api } from "@/auth/http"; +import { api as rpc } from "@/services/api"; +import { formatMoney } from "@/lib/format"; +import { + hasOddFt20, + type RebookPartnerCandidate, + type WagonCancellation, +} from "./types"; + + +/** Editable rebook unit — prefilled from the cancelled snapshot. */ +interface RebookUnitDraft { + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +/** Editable unit on the consolidation partner — prefilled from its own cargo. */ +interface PartnerUnitDraft { + id: string; + containerSize: string; + containerNumber: string; + sealNumber: string; + vgmTons: number | ""; +} + +const partnerDraftsFrom = (c: RebookPartnerCandidate | undefined) => + (c?.units ?? []).map((u) => ({ + id: u.id, + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || ("" as const), + })); + +/** Only the units GL actually changed are sent. */ +const partnerUnitsPayload = ( + drafts: PartnerUnitDraft[], + original: PartnerUnitDraft[], +) => + drafts + .filter((d, i) => { + const o = original[i]; + return ( + !o || + d.containerNumber !== o.containerNumber || + d.sealNumber !== o.sealNumber || + d.vgmTons !== o.vgmTons + ); + }) + .map((d) => ({ + id: d.id, + containerNumber: d.containerNumber.trim(), + sealNumber: d.sealNumber.trim(), + ...(d.vgmTons !== "" ? { vgmTons: Number(d.vgmTons) } : {}), + })); + +const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] => + (r.cancelledQuantities?.units ?? []).map((u) => ({ + containerSize: u.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? "", + vgmTons: Number(u.vgmTons) || "", + })); + +const containersPayload = (drafts: RebookUnitDraft[]) => { + const bySize = new Map(); + for (const d of drafts) { + bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]); + } + return [...bySize.entries()].map(([containerSize, units]) => ({ + containerSize, + units: units.map((u) => ({ + containerNumber: u.containerNumber.trim(), + ...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}), + ...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}), + })), + })); +}; + +/** + * Staff/GL rebook of a CREDIT_AVAILABLE wagon cancellation: pick the shipment + * day, correct container details if they changed, and — for an odd-20ft + * credit — pick the consolidation partner that shares the wagon. The server + * creates the new booking under the contract and marks it PAID from the credit. + * Used by the wagon-cancellations list, the GL clearance page and the staff + * booking page, so every desk gets the same flow. + */ +export function RebookWagonCancellationModal({ + cancellation, + onClose, + onRebooked, +}: { + cancellation: WagonCancellation | null; + onClose: () => void; + /** Called after a successful rebook with the new booking id (when the API returns it). */ + onRebooked?: (result: { bookingId?: string }) => void; +}) { + // Held as the picker's own `yyyy-MM-dd` string, never a Date: converting a + // local-midnight Date back with toISOString() shifts it into the previous day + // in any timezone east of UTC (EAT is +03), which both mis-rendered the + // selection and submitted the wrong shipment day. + const [date, setDate] = useState(null); + const [partnerId, setPartnerId] = useState(null); + const [partnerDrafts, setPartnerDrafts] = useState([]); + const [drafts, setDrafts] = useState([]); + + // Fresh form per row: the modal instance is long-lived on the host page. + useEffect(() => { + setDate(null); + setPartnerId(null); + setPartnerDrafts([]); + setDrafts(cancellation ? draftsFrom(cancellation) : []); + }, [cancellation]); + + const needsPartner = cancellation ? hasOddFt20(cancellation) : false; + + // The rebook rides the same lane with the same cargo as the cancelled + // shipment, so the shipment day must come from the days that lane actually + // runs — an arbitrary calendar day has no train and no wagon capacity. + const daysQuery = useMemo(() => { + const b = cancellation?.booking; + if (!b?.originYardId || !b?.destinationYardId) return null; + const containers = Object.entries( + cancellation?.cancelledQuantities?.bySize ?? {}, + ) + .map(([containerSize, quantity]) => ({ + containerSize, + quantity: Number(quantity || 0), + })) + .filter((c) => c.quantity >= 1); + if (containers.length > 0) { + return { + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + freightType: "CONTAINER" as const, + containers, + }; + } + const tons = Number(cancellation?.weightTons || 0); + if (tons <= 0) return null; + return { + originYardId: b.originYardId, + destinationYardId: b.destinationYardId, + freightType: "BULK" as const, + totalWeightTons: tons, + }; + }, [cancellation]); + + const { data: availableDays, isLoading: daysLoading } = useQuery({ + ...rpc.trainScheduling.availableDaysForCargo.queryOptions({ + input: daysQuery ?? { freightType: "BULK" as const }, + }), + enabled: Boolean(cancellation) && daysQuery !== null, + }); + const partners = useQuery({ + queryKey: [ + "wagon-cancellations", + cancellation?.id, + "rebook-partners", + date, + ], + enabled: Boolean(cancellation && needsPartner && date), + queryFn: async () => { + const res = await api.get( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`, + { params: { scheduledDate: date } }, + ); + return res.data; + }, + }); + + const rebook = useMutation({ + mutationFn: async () => { + const res = await api.post<{ bookingId?: string }>( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook`, + { + scheduledDate: date, + ...(drafts.length ? { containers: containersPayload(drafts) } : {}), + ...(partnerId ? { partnerBookingId: partnerId } : {}), + ...(() => { + if (!partnerId) return {}; + const original = partnerDraftsFrom( + (partners.data ?? []).find((c) => c.id === partnerId), + ); + const changed = partnerUnitsPayload(partnerDrafts, original); + return changed.length ? { partnerUnits: changed } : {}; + })(), + }, + ); + return res.data ?? {}; + }, + }); + + const patchPartnerDraft = (i: number, patch: Partial) => + setPartnerDrafts((prev) => + prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)), + ); + + const patchDraft = (i: number, patch: Partial) => + setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x))); + + return ( + + {cancellation && ( + + + {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} + {cancellation.wagonsCancelled} wagon(s) · credit{" "} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + + + Shipment day + + { + setDate(d || null); + setPartnerId(null); + setPartnerDrafts([]); + }} + /> + {needsPartner && ( +