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 a310e496b..84e3e4b7a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -96,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"; @@ -116,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"; @@ -258,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/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/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 314d31a7f..0354dbc84 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 @@ -232,3 +232,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 a84f0907f..4507e6479 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 @@ -53,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 }; @@ -134,6 +136,7 @@ export class BookingWagonCancellationService { private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, private readonly events: EventEmitter2, + private readonly wagonHistory: WagonHistoryService, ) {} // ── T1: request ──────────────────────────────────────────────────────────── @@ -1003,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) { @@ -1835,6 +1850,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); @@ -1881,9 +1897,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..381510e7e 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"; @@ -664,9 +666,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 +787,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 +918,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 +1198,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 +1209,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 +1355,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 +1367,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 +1394,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 +1867,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 +1880,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 +1891,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 +1900,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 +1914,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.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 5cdb0d6a4..c7294a004 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -29,6 +29,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'; @@ -112,6 +117,9 @@ 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. */ @@ -171,18 +179,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'); @@ -291,6 +304,8 @@ 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" @@ -303,13 +318,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, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, - s.train_number, s.scheduled_departure_date, so.label, sd.label + 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 @@ -344,17 +377,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], ) : []; @@ -388,6 +421,8 @@ 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. @@ -539,9 +574,9 @@ 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)} ${ @@ -558,16 +593,12 @@ export class BookingsService { const totalsRow = ` TOT ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} - ${ - pendingWagons - ? 'pending marshalling' - : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}` - } + ${num(totals.tare, 2)} ${num(totals.length)} ${num(totals.capacity)} - Gross ${num(totals.tare + totals.load)} T + @@ -575,6 +606,23 @@ export class BookingsService { ${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 ` @@ -591,6 +639,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; } @@ -652,6 +702,7 @@ export class BookingsService { ${totalsRow} +${footer}
${ @@ -2014,6 +2065,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/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/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..129789898 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 @@ -116,6 +116,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..b776a547c 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, 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/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index c38576fff..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, @@ -1359,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') @@ -1534,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/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/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 d9bae156a..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( @@ -4535,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. @@ -5489,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 @@ -5601,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 46cbdb2f6..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,12 +31,11 @@ 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, - notifyLoadManifest, -} 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. * @@ -65,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'; } @@ -121,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) @@ -192,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'); @@ -239,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 @@ -346,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', @@ -403,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 @@ -482,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', @@ -919,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( @@ -1012,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 4600f7f38..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,8 +53,10 @@ export class BookingNotifierService { try { const s = await this.trainSchedules.findByIdWithStations(scheduleId); if (!s) return fallback; - // Customers know the train by its operating number (8001), not the - // schedule reference — lead with it and keep S-… as the secondary id. + // 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 @@ -59,9 +75,9 @@ export class BookingNotifierService { hour12: false, })} EAT` : ''; - const number = s.trainNumber ?? s.reference ?? null; - return number - ? `train ${number}${number === s.reference ? '' : detail}${departure}` + if (run) return `${run}${detail}${departure}`; + return s.reference + ? `train ${s.reference}${departure}` : `${fallback}${detail}${departure}`; } catch (err) { this.logger.warn( @@ -71,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)' : ''}`; } @@ -162,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 { @@ -318,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}.`, + ); } /** @@ -340,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 64af1a56f..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, @@ -1185,6 +1188,57 @@ describe('TrainSchedulingService', () => { expect(html).toContain('Total containers1'); }); + 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, @@ -1211,6 +1265,29 @@ describe('TrainSchedulingService', () => { 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'); + }); + it('prints the consist-changes table for this stop, and omits it when there are none', () => { const schedule = { id: 'schedule-1', @@ -1444,6 +1521,22 @@ 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 @@ -1860,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 07d386df3..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'; @@ -72,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'; @@ -422,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. @@ -443,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}`); @@ -1170,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; } } @@ -1375,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); } } @@ -1872,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, @@ -2415,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); } @@ -2481,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); @@ -2531,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.`, @@ -2823,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); @@ -2992,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) @@ -3002,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 @@ -3039,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, @@ -3064,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', @@ -3075,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 @@ -3174,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 @@ -3190,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 ?? [], + })), + }, + }); } } @@ -3228,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, @@ -3244,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], ); @@ -3358,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); } } @@ -3602,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; }) @@ -3930,6 +4138,11 @@ export class TrainSchedulingService { // 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. @@ -4323,8 +4536,11 @@ export class TrainSchedulingService { // 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. - const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard); + // 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), @@ -4983,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 }, @@ -5069,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); @@ -5076,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, @@ -5108,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, @@ -5132,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({ @@ -5185,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, @@ -5202,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 @@ -5284,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) @@ -5510,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); @@ -5533,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, @@ -5562,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, @@ -5613,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, @@ -5650,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, @@ -5674,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); } @@ -5873,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, @@ -5909,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. @@ -6849,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); @@ -8925,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. @@ -8962,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( @@ -8977,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, { @@ -8984,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 @@ -10513,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 @@ -11508,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; } @@ -11522,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); @@ -11826,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; @@ -11836,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; } @@ -12216,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)) }, @@ -12227,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 26c29499d..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, @@ -25,6 +25,7 @@ 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'; @@ -76,6 +77,7 @@ export class TrainBuilderService { constructor( private readonly dataSource: DataSource, private readonly bookingBatchService: BookingBatchService, + private readonly wagonHistory: WagonHistoryService, ) {} async buildTrain(dto: BuildTrainDto) { @@ -134,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; }); @@ -684,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). @@ -699,6 +712,7 @@ export class TrainBuilderService { }), ); } + await this.wagonHistory.record(manager, events); }); return this.getComposition(id); } @@ -735,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); } @@ -787,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({ @@ -810,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, @@ -949,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. @@ -1166,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 @@ -1287,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( @@ -1299,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); }); @@ -1432,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 }); @@ -1543,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); @@ -1572,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, { @@ -1583,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-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts index 65edc5868..44862e2ff 100644 --- 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 @@ -2,7 +2,9 @@ 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"; @@ -14,7 +16,15 @@ import { TransitAssignmentsService } from "./transit-assignments.service"; // `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. - TypeOrmModule.forFeature([TransitAssignment, Booking]), + // 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, ], 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 index f23a8426a..68e856eff 100644 --- 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 @@ -38,6 +38,8 @@ describe("TransitAssignmentsService", () => { remove: jest.Mock; }; let service: TransitAssignmentsService; + let milestones: { find: jest.Mock }; + let trainSchedules: { find: jest.Mock }; const row = (over: Partial = {}) => ({ @@ -81,11 +83,16 @@ describe("TransitAssignmentsService", () => { 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, ); }); @@ -294,222 +301,127 @@ describe("TransitAssignmentsService", () => { 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}`, ...r } as never)), + 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(), ); - files.findByResourceIdsGrouped.mockResolvedValue(new Map()); }; - it("uses the median, so one reopened assignment cannot skew the headline", async () => { + it("measures transit from the train's departure to its arrival, using the median", async () => { withRows([ - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T10:35:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T12:10:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T13:45:00Z"), - }, - // 47h outlier: a mean would report ~12h, which describes nobody. - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-30T08:00:00Z"), - }, + { 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"); - // 95/190/285/2820 -> even count, so the median averages the middle two. - // A mean would be 848 minutes, describing none of the four. - expect(stats.performance.medianClearanceMinutes).toBe(238); - expect(stats.performance.slowestClearanceMinutes).toBe(2820); + 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("bands clearance times into the SLA buckets", async () => { - withRows([ + 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([ { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T10:30:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-28T13:00:00Z"), - }, - { - status: TransitAssignmentStatus.Finished, - finishedAt: at("2026-08-29T09:00:00Z"), + 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(stats.sla).toEqual({ under2h: 1, under6h: 1, over6h: 1 }); - expect(stats.performance.onTimeRate).toBe(67); + 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("counts coverage only over dispatched bookings", async () => { + it("points the officer at the next upload the detail page would actually allow", async () => { withRows([ - { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, - { booking: { arrivedAt: null, schedulingStatus: "DISPATCHED" } }, - // Scheduled bookings cannot receive documents yet, so counting them - // would report a failure the agent could not have avoided. - { booking: { arrivedAt: null, schedulingStatus: "SCHEDULED" } }, + // 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(stats.coverage.dispatched).toBe(2); - expect(stats.coverage.withDocuments).toBe(0); + 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 }]); + withRows([{ status: TransitAssignmentStatus.NotStarted, booking: { arrivedAt: null } }]); + bookingFiles({}); const stats = await service.myStats("user-1"); - expect(stats.performance.medianClearanceMinutes).toBeNull(); - expect(stats.performance.onTimeRate).toBeNull(); + expect(stats.timings.transit.median).toBeNull(); + expect(stats.timings.arrivalToFinish.median).toBeNull(); expect(stats.totals.open).toBe(1); }); }); - - describe("customerName", () => { - it("flattens the booking's company name", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - booking: { - id: "bk-1", - arrivedAt: ARRIVED, - schedulingStatus: "DISPATCHED", - company: { name: "SHAFICI PHARMACEUTICAL" }, - } as never, - }), - ); - - expect((await service.findById("ta-1")).customerName).toBe( - "SHAFICI PHARMACEUTICAL", - ); - }); - - it("is null when the booking has no company", async () => { - expect((await service.findById("ta-1")).customerName).toBeNull(); - }); - }); - - describe("canUploadDocuments", () => { - it("is true for an open assignment on a dispatched booking", async () => { - expect((await service.findById("ta-1")).canUploadDocuments).toBe(true); - }); - - it("is false before dispatch", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - booking: { - id: "bk-1", - arrivedAt: null, - schedulingStatus: "SCHEDULED", - } as never, - }), - ); - expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); - }); - - it("is false once finished", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - status: TransitAssignmentStatus.Finished, - finishedAt: new Date(), - }), - ); - expect((await service.findById("ta-1")).canUploadDocuments).toBe(false); - }); - }); - - describe("portal scoping", () => { - it("hides another agent's assignment behind a NotFound", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ transitAgentId: "someone-else" }), - ); - - await expect(service.findMineById("user-1", "ta-1")).rejects.toThrow( - NotFoundException, - ); - }); - - it("rejects an account that is not a transit agent", async () => { - agents.findByUserId.mockResolvedValue(null); - - await expect(service.findMine("user-1")).rejects.toThrow( - ForbiddenException, - ); - }); - - it("pins the query to the session's agent and passes the filters through", async () => { - await service.findMine("user-1", { - search: "BK-2026", - status: TransitAssignmentStatus.InProgress, - schedulingStatus: "DISPATCHED", - page: 2, - pageSize: 10, - }); - - const [agentId, filter, skip, take] = - assignments.findByTransitAgentPaginated.mock.calls[0]; - // The agent id comes from the session, never from the query — otherwise - // one agent could page through another agent's work. - expect(agentId).toBe("ag-1"); - expect(filter).toMatchObject({ - search: "BK-2026", - status: TransitAssignmentStatus.InProgress, - schedulingStatus: "DISPATCHED", - }); - expect(skip).toBe(10); - expect(take).toBe(10); - }); - - it("reports pagination meta", async () => { - assignments.findByTransitAgentPaginated.mockResolvedValue([[], 45]); - - const result = await service.findMine("user-1", { pageSize: 20 }); - - expect(result.meta).toEqual({ - total: 45, - page: 1, - pageSize: 20, - totalPages: 3, - }); - }); - - it("save moves the assignment to IN_PROGRESS, finish closes it", async () => { - await service.submitMine("user-1", "ta-1", { finish: false }); - expect(assignments.update.mock.calls[0][1].status).toBe( - TransitAssignmentStatus.InProgress, - ); - - assignments.update.mockClear(); - await service.submitMine("user-1", "ta-1", { finish: true }); - expect(assignments.update.mock.calls[0][1].status).toBe( - TransitAssignmentStatus.Finished, - ); - }); - - it("refuses to re-submit an already finished assignment", async () => { - assignments.findOneWithRelations.mockResolvedValue( - row({ - status: TransitAssignmentStatus.Finished, - finishedAt: new Date(), - }), - ); - - await expect( - service.submitMine("user-1", "ta-1", { finish: true }), - ).rejects.toThrow(ForbiddenException); - }); - }); }); 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 index 389e08fac..80f8d6be9 100644 --- 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 @@ -7,10 +7,19 @@ import { } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "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"; @@ -66,6 +75,86 @@ export type TransitAssignmentView = TransitAssignment & { 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( @@ -77,6 +166,10 @@ export class TransitAssignmentsService { @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( @@ -179,98 +272,302 @@ export class TransitAssignmentsService { * reopened days later drags an average far enough to make the whole panel * lie about typical performance. */ - async myStats(userId: string) { + /** + * 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 docCounts = rows.length - ? await this.filesService.findByResourceIdsGrouped( - rows.map((r) => r.id), - TRANSIT_ASSIGNMENT_FILE_RESOURCE, - ) - : new Map(); + 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 minutes = (from?: Date | null, to?: Date | null) => - from && to ? Math.floor((to.getTime() - from.getTime()) / 60_000) : null; + 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" }; + } - const items = rows.map((row) => { - const arrivedAt = row.booking?.arrivedAt ?? null; return { id: row.id, - reference: row.booking?.reference ?? null, - customerName: row.booking?.company?.name ?? null, + bookingId: row.bookingId, + reference: booking?.reference ?? null, + customerName: booking?.company?.name ?? null, + tradeDirection, status: row.status, - schedulingStatus: row.booking?.schedulingStatus ?? null, - /** Dispatch (cargo loaded) to the train arriving. */ - transitMinutes: minutes(row.booking?.loadedAt, arrivedAt), - /** Arrival to the agent picking the work up. */ - pickupMinutes: minutes(arrivedAt, row.startedAt), - /** Arrival to the work being finished — the headline metric. */ - clearanceMinutes: minutes(arrivedAt, row.finishedAt), - documentCount: (docCounts.get(row.id) ?? []).length, + 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, }; }); - const median = (values: number[]): number | null => { - if (!values.length) return null; - const sorted = [...values].sort((a, b) => a - b); + // 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 sorted.length % 2 - ? sorted[mid] - : Math.round((sorted[mid - 1] + sorted[mid]) / 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 cleared = items - .map((i) => i.clearanceMinutes) - .filter((v): v is number => v !== null); - const pickups = items - .map((i) => i.pickupMinutes) - .filter((v): v is number => v !== null); - - // SLA bands, in minutes: inside 2h, inside 6h, beyond. - const sla = { - under2h: cleared.filter((v) => v <= 120).length, - under6h: cleared.filter((v) => v > 120 && v <= 360).length, - over6h: cleared.filter((v) => v > 360).length, - }; - - // Coverage counts only bookings that COULD have documents — uploads are - // gated on dispatch, so counting scheduled ones would invent a failure. - const dispatched = items.filter((i) => i.schedulingStatus === "DISPATCHED"); - const withDocs = dispatched.filter((i) => i.documentCount > 0).length; + 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: items.filter((i) => i.status !== TransitAssignmentStatus.Finished) + open: open.length, + notStarted: items.filter((i) => i.status === TransitAssignmentStatus.NotStarted) .length, - finished: items.filter( - (i) => i.status === TransitAssignmentStatus.Finished, - ).length, - readyForDocuments: items.filter( - (i) => - i.schedulingStatus === "DISPATCHED" && - i.status !== TransitAssignmentStatus.Finished, - ).length, - documents: items.reduce((sum, i) => sum + i.documentCount, 0), + 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, }, - performance: { - medianClearanceMinutes: median(cleared), - medianPickupMinutes: median(pickups), - fastestClearanceMinutes: cleared.length ? Math.min(...cleared) : null, - slowestClearanceMinutes: cleared.length ? Math.max(...cleared) : null, - onTimeRate: cleared.length - ? Math.round(((sla.under2h + sla.under6h) / cleared.length) * 100) - : null, - measured: cleared.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"), }, - sla, - coverage: { - dispatched: dispatched.length, - withDocuments: withDocs, + documents: { + ro: sumDocs("ro"), + do: sumDocs("do"), + t1: sumDocs("t1"), + gatePass: sumDocs("gatePass"), + djiboutiT1: sumDocs("djiboutiT1"), + own: sumDocs("own"), }, - /** Newest first, for the timeline and the recent-activity list. */ - items: items.slice(0, 12), + pending: { + ro: pendingFor("ro"), + do: pendingFor("do"), + t1: pendingFor("t1"), + gatePass: pendingFor("gate_pass"), + djiboutiT1: pendingFor("djibouti_t1"), + }, + items: items.slice(0, 20), }; } @@ -393,6 +690,50 @@ export class TransitAssignmentsService { 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, 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.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 43e7d7ae8..82231bacc 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' @@ -1636,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'); @@ -1646,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') { @@ -1711,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}` @@ -1738,7 +1988,19 @@ export class WarehouseInventoryService { }); 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, + } + : {}), + }); } }); @@ -1842,6 +2104,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 @@ -1905,7 +2169,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 @@ -1913,6 +2185,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 @@ -1935,9 +2208,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, })); } @@ -1994,6 +2272,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, { @@ -3494,12 +3777,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) => { @@ -3991,7 +4276,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, @@ -4320,11 +4605,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 @@ -4336,6 +4622,7 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, weightTons: Number(r.weightTons) || 0, + containerSize: r.containerSize ?? null, })); } @@ -4537,7 +4824,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, @@ -4554,8 +4841,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", @@ -4585,6 +4872,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 @@ -7155,12 +7476,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 { @@ -7168,7 +7496,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`, @@ -7182,6 +7511,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 2a8ac578e..0a3d26d71 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1490,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( @@ -1937,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, @@ -2418,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", @@ -2831,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, 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/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx new file mode 100644 index 000000000..2d37be840 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -0,0 +1,235 @@ +import { useEffect, useState } from "react"; +import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/auth/http"; +import { toDayString } from "@/hooks/useListControls"; +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 | ""; +} + +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; +}) { + const [date, setDate] = useState(null); + const [partnerId, setPartnerId] = useState(null); + const [drafts, setDrafts] = useState([]); + + // Fresh form per row: the modal instance is long-lived on the host page. + useEffect(() => { + setDate(null); + setPartnerId(null); + setDrafts(cancellation ? draftsFrom(cancellation) : []); + }, [cancellation]); + + const needsPartner = cancellation ? hasOddFt20(cancellation) : false; + const partners = useQuery({ + queryKey: [ + "wagon-cancellations", + cancellation?.id, + "rebook-partners", + date ? toDayString(date) : null, + ], + enabled: Boolean(cancellation && needsPartner && date), + queryFn: async () => { + const res = await api.get( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`, + { params: { scheduledDate: toDayString(date!) } }, + ); + return res.data; + }, + }); + + const rebook = useMutation({ + mutationFn: async () => { + const res = await api.post<{ bookingId?: string }>( + `/bookings/wagon-cancellations/${cancellation!.id}/rebook`, + { + scheduledDate: toDayString(date!), + ...(drafts.length ? { containers: containersPayload(drafts) } : {}), + ...(partnerId ? { partnerBookingId: partnerId } : {}), + }, + ); + return res.data ?? {}; + }, + }); + + 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)} + + { + setDate(v ? new Date(v) : null); + setPartnerId(null); + }} + minDate={new Date()} + radius="md" + /> + {needsPartner && ( +