Merge pull request #1481 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-09-03 03:31:38 +03:00
committed by GitHub
205 changed files with 20564 additions and 2385 deletions

BIN
INV-20260812-00005-mor.pdf Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -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",

View File

@@ -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,

View File

@@ -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([]);
});
});

View File

@@ -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<string>();
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<boolean> {
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<SnapshotPosition[]> {
): Promise<SnapshotEmployee[]> {
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;
}
}

View File

@@ -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<Parameters<typeof edrHaulsThisBooking>[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);
});
});

View File

@@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE =
'This booking is delivered by the customers 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

View File

@@ -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({

View File

@@ -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',
);
}
}

View File

@@ -26,6 +26,20 @@ const withEnv = (vars: Record<string, string | undefined>, 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" },

View File

@@ -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<string, string>, RegExp]> = [
["unknown country", { ...JIJIGA, country: "Wakanda" }, /no MoR COUNTRY_NAME match/],

View File

@@ -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). */

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_events`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`);
}
}

View File

@@ -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<unknown> {
// 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<RequestWithUser>();
@@ -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;
}

View File

@@ -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),
),
]),
];

View File

@@ -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],

View File

@@ -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(

View File

@@ -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<void> {
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<string | null> {
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<string, { am: string; en: string }> = {
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 },
};
}

View File

@@ -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<NonNullable<InvoiceDocumentModel["mor"]>> = {}) =>
({
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<InvoiceDocumentModel["mor"]>;
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");
});
});

View File

@@ -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) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.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]) =>
`<div class="total-row"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`,
)
.join("") +
`<div class="total-row grand"><span>Total incl. Tax</span><strong>${esc(money(tax.totalIncludingTax, model.currency))}</strong></div>` +
`<div class="words">${esc(tax.amountInWords)}</div>`
: model.totals
.map(
(total) =>
`<div class="total-row${total.grand ? " grand" : ""}"><span>${esc(total.label)}</span><strong>${esc(money(total.amount, model.currency))}</strong></div>`,
)
.join("");
const payMarkup = model.mor?.payment
? `<div class="rule"></div><div class="row"><span class="label">Mode of Payment</span><span class="value">${esc(model.mor.payment.mode)}</span></div>
<div class="row"><span class="label">Type/Method</span><span class="value">${esc(model.mor.payment.typeMethod)}</span></div>`
: "";
const qrMarkup = model.qrImageUrl
? `<div class="qr"><img src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" /><div class="qr-caption">Scan to verify (MoR EIMS)</div></div>`
@@ -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}
<div class="rule"></div>
${totalRows}
${payMarkup}
${qrMarkup}
<div class="footer">Thank you</div>
</div>
@@ -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 {
</html>`;
}
/**
* 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 => `
<table class="party">
<tr><th class="side"><span class="am">${esc(sideAm)}</span><span class="en">${esc(sideEn)}</span></th>
<td class="pname">${esc(p.name)}</td></tr>
${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)}
</table>`;
const itemRows = model.lines
.map(
(item, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(item.description)}</td>
<td>${esc(item.nature ?? "-")}</td>
<td>${esc(item.uom ?? "-")}</td>
<td class="num">${esc(item.quantity ?? 0)}</td>
<td class="num">${esc(amount2(item.unitRate))}</td>
<td>${esc(item.taxCode ?? "-")}</td>
<td class="num">${esc(amount2(item.excise ?? 0))}</td>
<td class="num">${esc(amount2(item.discount ?? 0))}</td>
<td class="num strong">${esc(amount2(item.amount))}</td>
</tr>`,
)
.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
? `<tr class="words"><td class="wl"><span class="am">ጠቅላላ ዋጋ ከታክስ ጋር (በፊደል)</span><span class="en">Total including Tax (in words)</span></td>
<td class="wv">${esc(tax.amountInWords)}</td></tr>`
: "";
const receipt = mor.receipt;
const receiptBlock = receipt
? `<table class="kv">
${morRow("የክፍያ ምክንያት", "Payment Reason", receipt.reason)}
${morRow("የተሰበሰበ መጠን", "Collected Amount", amount2(receipt.collectedAmount))}
</table>
<div class="sec">የደረሰኞች ዝርዝር / Invoices</div>
<table class="items">
<thead><tr>
<th>IRN</th>
<th>${esc("የክፍያ ሽፋን / Payment Coverage")}</th>
<th class="num">${esc("ጠቅላላ ዋጋ / Total Amount")}</th>
<th class="num">${esc("ቀሪ / Remaining Amount")}</th>
<th class="num">${esc("የተከፈለ / Paid Amount")}</th>
</tr></thead>
<tbody>${receipt.invoices
.map(
(inv) => `<tr>
<td class="irn">${esc(inv.irn)}</td>
<td>${esc(inv.paymentCoverage)}</td>
<td class="num">${esc(amount2(inv.totalAmount))}</td>
<td class="num">${esc(amount2(inv.remainingAmount))}</td>
<td class="num strong">${esc(amount2(inv.paidAmount))}</td>
</tr>`,
)
.join("")}</tbody>
</table>
<div class="paid-total">ጠቅላላ የተከፈለ መጠን / Total Paid: <strong>${esc(amount2(receipt.collectedAmount))}</strong></div>`
: "";
const wh = mor.withholding;
const withholdingBlock = wh
? `<table class="kv">
${morRow("የደረሰኝ ቁጥር", "Receipt #", wh.receiptNumber)}
${morRow("ቆጣሪ", "Counter", wh.counter)}
${morRow("ምክንያት", "Reason", wh.reason)}
${morRow("አይነት", "Type", wh.type)}
</table>
<table class="items">
<thead><tr>
<th>${esc("የደረሰኝ ቁጥር / Invoice Doc. Number")}</th>
<th>${esc("የገንዘብ ዓይነት / Invoice Currency")}</th>
<th class="num">${esc("ከታክስ በፊት ያለው ዋጋ / Pre Tax Amount")}</th>
<th class="num">${esc("ተይዞ የቀረ መጠን / Withheld Amount")}</th>
</tr></thead>
<tbody><tr>
<td>${esc(wh.receiptNumber)}</td>
<td>${esc(wh.invoiceCurrency)}</td>
<td class="num">${esc(amount2(wh.preTaxAmount))}</td>
<td class="num strong">${esc(amount2(wh.withheldAmount))}</td>
</tr></tbody>
</table>
<div class="paid-total">በገዥ ተይዞ የቀረ መጠን / Withheld Amount: <strong>${esc(amount2(wh.withheldAmount))}</strong></div>
<table class="kv sys">
${morRow("የስርዓት አይነት", "System Type", wh.systemType)}
${morRow("የስርዓት ቁጥር", "System Number", wh.systemNumber)}
</table>`
: "";
const payment = mor.payment;
const paymentBlock = payment
? `<table class="pay">
<tr>
<td><span class="am">የክፍያ ሁኔታ</span><span class="en">Mode of Payment</span><strong>${esc(payment.mode)}</strong></td>
<td><span class="am">አይነት</span><span class="en">Type/Method</span><strong>${esc(payment.typeMethod)}</strong></td>
<td><span class="am">የተቀባይ ስምና ፊርማ</span><span class="en">Receiver Name &amp; Signature</span><strong>${esc(payment.receiverName ?? "")}</strong></td>
</tr>
</table>`
: "";
const approval = mor.approval;
const approvalBlock = approval
? `<div class="amend">INVOICE AMENDMENT AUTHORIZATION</div>
<div class="amend-note">This amendment has been reviewed and approved in accordance with the company's approval matrix.</div>
<table class="pay">
<tr>
<td><span class="am">የጠየቀው</span><span class="en">Requested By</span><strong>${esc(approval.requestedBy ?? "")}</strong></td>
<td><span class="am">ያረጋገጠው</span><span class="en">Checked By</span><strong>${esc(approval.checkedBy ?? "")}</strong></td>
<td><span class="am">ያፀደቀው</span><span class="en">Approved By</span><strong>${esc(approval.approvedBy ?? "")}</strong></td>
</tr>
</table>`
: "";
const qrBlock = model.qrImageUrl
? `<img class="qr" src="${esc(model.qrImageUrl)}" alt="EIMS verification QR" />`
: "";
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${esc(mor.titleEn)} ${esc(model.documentNumber)}</title>
<style>
@page { size: A4; margin: 10mm 9mm 12mm; }
body { font-family: "Noto Sans Ethiopic", "Abyssinica SIL", Arial, sans-serif; color: #111827; margin: 0; font-size: 9.5px; }
.doc { position: relative; }
.am { display: block; font-size: 8px; color: #374151; }
.en { display: block; font-size: 8.5px; color: #6b7280; }
/* Header ------------------------------------------------------------- */
.hdr { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #0f766e; padding-bottom: 6px; }
.hdr-logo { max-height: 42px; max-width: 150px; object-fit: contain; display: block; margin-bottom: 4px; }
.org { font-size: 12px; font-weight: 700; color: #0f172a; }
.org-sub { font-size: 8.5px; color: #4b5563; line-height: 1.45; }
.hdr-meta { text-align: right; font-size: 8.5px; }
.hdr-meta div { margin-bottom: 2px; }
.hdr-meta b { display: inline-block; min-width: 92px; text-align: right; color: #111827; }
/* Title -------------------------------------------------------------- */
.title { text-align: center; margin: 8px 0 4px; }
.title .t-am { font-size: 12px; font-weight: 700; }
.title .t-en { font-size: 11.5px; font-weight: 700; text-decoration: underline; }
.title .t-type { font-size: 9.5px; color: #4b5563; margin-top: 2px; }
/* Identity strip ----------------------------------------------------- */
.ident { display: flex; justify-content: space-between; gap: 10px; margin: 6px 0 8px; }
.ident table { border-collapse: collapse; }
.ident td { padding: 1.5px 0; vertical-align: top; font-size: 8.5px; }
.ident td.k { color: #6b7280; padding-right: 8px; white-space: nowrap; }
.ident td.v { font-weight: 600; word-break: break-all; max-width: 330px; }
.qr { width: 96px; height: 96px; flex: none; }
/* Parties ------------------------------------------------------------ */
.parties { display: flex; gap: 8px; }
.parties > div { flex: 1; min-width: 0; }
table.party { width: 100%; border-collapse: collapse; border: 1px solid #9ca3af; }
table.party th, table.party td { border: 1px solid #d1d5db; padding: 2.5px 5px; text-align: left; vertical-align: top; font-weight: normal; }
table.party th.side { width: 42%; background: #f9fafb; }
table.party td.pname { font-weight: 700; font-size: 10px; }
table.party td.pv { font-weight: 600; word-break: break-all; }
/* Items -------------------------------------------------------------- */
.sec { margin: 8px 0 3px; font-size: 9px; font-weight: 700; color: #374151; }
table.items { width: 100%; border-collapse: collapse; margin-top: 6px; table-layout: fixed; }
table.items th { background: #f3f4f6; font-size: 7.5px; color: #374151; }
table.items th, table.items td { border: 1px solid #9ca3af; padding: 3px 4px; text-align: left; word-wrap: break-word; }
table.items td { font-size: 8.5px; }
table.items .num { text-align: right; }
table.items .strong { font-weight: 700; }
table.items td.irn { font-size: 7px; word-break: break-all; }
/* Totals ------------------------------------------------------------- */
table.totals { width: 100%; border-collapse: collapse; margin-top: -1px; }
table.totals td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
table.totals td.tl { text-align: right; }
table.totals td.tv { text-align: right; width: 130px; font-weight: 600; }
table.totals tr.grand td { font-weight: 800; font-size: 10px; background: #f9fafb; }
table.totals tr.words td { padding: 4px 6px; }
table.totals td.wl { width: 240px; }
table.totals td.wv { font-weight: 700; text-align: center; }
/* Key/value + payment ------------------------------------------------ */
table.kv { width: 100%; border-collapse: collapse; margin-top: 6px; }
table.kv td { border: 1px solid #9ca3af; padding: 3px 6px; font-size: 8.5px; }
table.kv td.k { width: 220px; background: #f9fafb; }
table.kv td.v { font-weight: 600; }
table.kv.sys { margin-top: 10px; }
.paid-total { text-align: right; font-size: 9px; margin-top: 4px; }
table.pay { width: 100%; border-collapse: collapse; margin-top: 10px; }
table.pay td { border: 1px solid #9ca3af; padding: 4px 6px; width: 33.33%; }
table.pay strong { display: block; font-size: 10px; margin-top: 2px; }
.amend { margin-top: 12px; text-align: center; font-weight: 800; font-size: 10px; color: #b91c1c; letter-spacing: .04em; }
.amend-note { text-align: center; font-size: 8px; color: #6b7280; }
/* Footer ------------------------------------------------------------- */
.foot { margin-top: 14px; border-top: 1px solid #d1d5db; padding-top: 4px; display: flex; justify-content: space-between; font-size: 7.5px; color: #6b7280; }
</style>
</head>
<body>
<div class="doc">
<div class="hdr">
<div>
${model.logoImageUrl ? `<img class="hdr-logo" src="${esc(model.logoImageUrl)}" alt="EDR" />` : ""}
<div class="org">${esc(mor.seller.name)}</div>
<div class="org-sub">Ethio-Djibouti Railway S.C.</div>
</div>
<div class="hdr-meta">
<div><span class="am">የደረሰኝ ቁጥር</span><span class="en">Document No</span><b>${esc(model.documentNumber)}</b></div>
<div><span class="am">ቀን</span><span class="en">Date</span><b>${esc(formatEthiopianDate(model.issuedAt))}</b></div>
<div><b>${esc(formatGregorianDate(model.issuedAt))}</b></div>
<div><span class="am">ሰአት</span><span class="en">Time</span><b>${esc(formatDocumentTime(model.issuedAt))}</b></div>
</div>
</div>
<div class="title">
<div class="t-am">${esc(mor.titleAm)}</div>
<div class="t-en">${esc(mor.titleEn)}</div>
${mor.saleType ? `<div class="t-type">የሽያጭ አይነት (${esc(mor.saleType)})</div>` : ""}
</div>
<div class="ident">
<table>
${mor.irn ? `<tr><td class="k">IRN</td><td class="v">${esc(mor.irn)}</td></tr>` : ""}
${mor.receipt ? `<tr><td class="k">RRN</td><td class="v">${esc(mor.receipt.rrn)}</td></tr>` : ""}
${mor.systemNumber ? `<tr><td class="k">System Number</td><td class="v">${esc(mor.systemNumber)}</td></tr>` : ""}
${mor.referenceNumber ? `<tr><td class="k">Reference Number</td><td class="v">${esc(mor.referenceNumber)}</td></tr>` : ""}
${mor.relatedDocumentIrn ? `<tr><td class="k">Related Document</td><td class="v">${esc(mor.relatedDocumentIrn)}</td></tr>` : ""}
</table>
${qrBlock}
</div>
<div class="parties">
<div>${party(mor.seller, "ከ", "From", "የሻጭ", "Seller")}</div>
<div>${party(mor.buyer, "ለ", "To", "የገዢ", "Customer")}</div>
</div>
${withholdingBlock}
${receiptBlock}
${
model.lines.length > 0 && !mor.withholding
? `<table class="items">
<thead>
<tr>
<th style="width:4%">${esc("ተ/ቁ")}<br/>No.</th>
<th style="width:24%">${esc("የዕቃው / አገልግሎት አይነት")}<br/>Description</th>
<th style="width:9%">${esc("ምድብ")}<br/>Nature</th>
<th style="width:7%">${esc("መለኪያ")}<br/>UoM</th>
<th style="width:7%" class="num">${esc("ብዛት")}<br/>Qty</th>
<th style="width:12%" class="num">${esc("የአንዱ ዋጋ")}<br/>Unit Price</th>
<th style="width:9%">${esc("ታክስ ኮድ")}<br/>Tax Code</th>
<th style="width:9%" class="num">${esc("ኤክሳይዝ")}<br/>Excise</th>
<th style="width:9%" class="num">${esc("ቅናሽ")}<br/>Discount</th>
<th style="width:14%" class="num">${esc("ጠቅላላ ዋጋ")}<br/>Total Amount</th>
</tr>
</thead>
<tbody>${itemRows}</tbody>
</table>`
: ""
}
${tax ? `<table class="totals">${taxRows}${wordsRow}</table>` : ""}
${paymentBlock}
${approvalBlock}
<div class="foot">
<div>Ethio-Djibouti Railway S.C. — ${esc(mor.titleEn)}</div>
<div>Page 1 of 1 &nbsp;·&nbsp; Printed ${esc(formatGregorianDate(new Date()))} ${esc(formatDocumentTime(new Date()))}</div>
</div>
</div>
</body>
</html>`;
}
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 `<tr><td class="k"><span class="am">${esc(amOverride ? `${amOverride} ${am}` : am)}</span><span class="en">${esc(en)}</span></td><td class="v pv">${esc(
value === null || value === undefined || value === "" ? "N/A" : value,
)}</td></tr>`;
}
/** One row of the Ministry's totals block. */
function totalRow(am: string, en: string, value: string, grand = false): string {
return `<tr class="${grand ? "grand" : ""}"><td class="tl">${esc(am ? `${am} / ${en}` : en)}</td><td class="tv">${esc(value)}</td></tr>`;
}

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

@@ -370,7 +370,21 @@ export class BookingTransitionService {
async startTransit(bookingId: string): Promise<Booking> {
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,
};

View File

@@ -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<string, unknown>) => {
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
string,
unknown
> & { rebook(id: string, dto: unknown): Promise<unknown> };
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<string, unknown>) => ({
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);
});
});

View File

@@ -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<void> {
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,

View File

@@ -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<void> {
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<void> {
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<void> {
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,

View File

@@ -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<Booking> {
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 {
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.departureStation ?? departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
@@ -558,16 +593,12 @@ export class BookingsService {
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
}</td>
<td></td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td></td>
<td>Gross ${num(totals.tare + totals.load)} T</td>
<td></td>
<td></td>
<td></td>
<td></td>
@@ -575,6 +606,23 @@ export class BookingsService {
<td class="num">${money(totalAmount)}</td>
</tr>`;
// 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 = `
<div class="summary footer-summary">
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
<div class="tile"><span>Empty Wagon</span><strong>${
pendingWagons ? '-' : loadedWagons.length - fullWagons
}</strong></div>
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
</div>`;
return `<!doctype html>
<html>
<head>
@@ -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}
</tbody>
</table>
${footer}
<div class="notice">
${
@@ -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<boolean> {
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<boolean> {
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<void> {
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

View File

@@ -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, unknown> = {}): 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<string, unknown> = {}) => ({
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('<td>Dire Dawa Port</td>');
expect(html).toContain('<td>Adama</td>');
expect(html).toContain('<td>Booking Origin</td>');
expect(html).toContain('<td>Booking Destination</td>');
});
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('<span>In Total Wagon No.</span><strong>2</strong>');
expect(html).toContain('<span>Tare Weight (T)</span><strong>40.00</strong>');
expect(html).toContain('<span>Load Capacity (T)</span><strong>120.000</strong>');
expect(html).toContain('<span>Gross Weight (T)</span><strong>80.000</strong>');
expect(html).toContain('<span>Equated Length</span><strong>28.000</strong>');
expect(html).toContain('<span>Full Wagon</span><strong>1</strong>');
expect(html).toContain('<span>Empty Wagon</span><strong>1</strong>');
expect(html).toContain('<span>Total Amount (ETB)</span><strong>100.00</strong>');
});
});

View File

@@ -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<BulkTruckUploadResult> {
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',
});
}
}

View File

@@ -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',
})

View File

@@ -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[];
}

View File

@@ -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;

View File

@@ -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',
})

View File

@@ -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',
})

View File

@@ -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<ChatConfig> = {}) {
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();
});
});

View File

@@ -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<Record<NotificationType, { alias: string; name: string }>> = {
[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<Record<NotificationType, { alias: string; name: str
*
* Gated on BACKOFFICE only: notify() also serves PORTAL (customer)
* notifications, which must never land in an internal staff room.
*
* Everything goes to one room. 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, which is `edr_freight_app/opn`
* shaped, so `#dept-operation` matched nothing it creates. The bridge quietly
* created its own empty room and posted every notification into it, where no
* employee was a member. A single room the reconcile actually populates beats
* per-type routing that silently misses; add routing back when real usage asks
* for it, keyed off the same derivation the reconcile uses.
*/
@Injectable()
export class ChatBridgeService {
@@ -46,8 +38,9 @@ export class ChatBridgeService {
if (!this.config.enabled) return;
try {
const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM;
const roomId = await this.matrix.ensureRoom(room.alias, room.name);
// get-or-create as a safety net only: the reconcile creates this room
// inside the space and joins every position holder to it.
const roomId = await this.matrix.ensureRoom(ALERTS_ROOM.alias, ALERTS_ROOM.name);
const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`;
const html = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''

View File

@@ -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<string, string[]> = {}) {
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<string>,
bot: string,
) => Promise<{ joined: number; kicked: string[] }>;
}
).syncMembership('!room:m.test', new Set<string>(), BOT);
expect(diff).toEqual({ joined: 0, kicked: [] });
expect(matrix.kick).not.toHaveBeenCalled();
});
});

View File

@@ -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<string>,
@@ -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<ReconcileResult> {
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<string>();
// 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<string, { name: string; userIds: Set<string> }>();
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 };
}
}

View File

@@ -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 {}

View File

@@ -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);
});
});

View File

@@ -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<typeof chatConfig>,
@@ -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<typeof fetch>[1],
): Promise<Awaited<ReturnType<typeof fetch>>> {
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<T>(
method: string,
path: string,
body?: unknown,
token: string = this.config.adminToken,
): Promise<T> {
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<T> {
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<T | null> {
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<AdminCheck> {
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<AdminCheck> {
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<void> {
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<string[]> {
const res = await this.request<{ joined: Record<string, unknown> }>(
@@ -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<void> {
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<void> {
/**
* 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<void> {
return this.setLocked(userId, true);
}
private setLocked(userId: string, locked: boolean): Promise<void> {
return this.request(
'POST',
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
{ erase: false },
'PUT',
`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`,
{ locked },
);
}

View File

@@ -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<ContainerType>,
private readonly dataSource: DataSource,
private readonly wagonHistory: WagonHistoryService,
) {}
async create(dto: CreateContainerDto): Promise<Container> {
@@ -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;
}
}

View File

@@ -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

View File

@@ -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<string, unknown> | 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<void> {
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,

View File

@@ -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<string, unknown> | 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;

View File

@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // glOperationsService
notifier as never,
{} as never, // transitAgentsService
{} as never, // transitAssignmentsService
{} as never, // dataSource
);
build([

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => {
{} as never, // milestoneService
billingService as never,
notifier as never,
{ record: jest.fn() } as never, // clearanceEvents
);
});

View File

@@ -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<GlExchangeActor> {
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) &&

View File

@@ -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,

View File

@@ -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 };
}

View File

@@ -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<unknown>;
};
@@ -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<void> {
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<ClearanceWorkflowFile['file']> {
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;

View File

@@ -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
);
});

View File

@@ -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 };
});

View File

@@ -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: <T>(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();
});
});

View File

@@ -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<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
@@ -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<TRequest, TResponse>(path: string, request: TRequest): Promise<TResponse> {
return this.send<TRequest, TResponse>(path, request, false, false);
@@ -61,7 +58,11 @@ export class EimsClientService {
try {
const res = await firstValueFrom(
this.http.post<TResponse>(`${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,
}),
);

View File

@@ -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!,

View File

@@ -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/);

View File

@@ -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<EimsVerifyResponse> {
const response = await this.client.postBearer<EimsVerifyRequest, EimsVerifyResponse>(

View File

@@ -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 {

View File

@@ -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 () => {

View File

@@ -196,7 +196,7 @@ export class EimsReceiptService {
let model: ReturnType<typeof toReceiptDocumentModel>;
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

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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<EmptyReturnRequest> {
constructor(
@InjectRepository(EmptyReturnRequest)
repository: Repository<EmptyReturnRequest>,
) {
super(repository);
}
}

View File

@@ -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<typeof booking>;
request?: Partial<EmptyReturnRequest>;
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<EmptyReturnRequest>) => ({ ...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();
});
});

View File

@@ -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<EmptyReturnRequest & { bookingReference: string | null; companyName: string | null }>
> {
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<EmptyReturnRequest> {
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<EmptyReturnRequest[]> {
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<EmptyReturnEligibility> {
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<EmptyReturnQuote> {
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<EmptyReturnRequest> {
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<EmptyReturnRequest>);
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<EmptyReturnRequest> {
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<EmptyReturnRequest>);
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<EmptyReturnRequest> {
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<EmptyReturnRequest>);
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<EmptyReturnRequest> {
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<EmptyReturnRequest>);
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<void> {
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<EmptyReturnRequest>);
}
}
// ── payment ──────────────────────────────────────────────────────────────
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent(`${INVOICE_SOURCE}.invoice.paid`)
async onInvoicePaid(payload: { sourceId: string }): Promise<void> {
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<EmptyReturnRequest>);
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<string[]> {
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<Set<string>> {
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<void> {
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(', ')}`,
);
}
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -56,3 +56,31 @@ export function pickByKey<T extends { key: string }>(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<T extends { key: string; default?: boolean }>(
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;
}

View File

@@ -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}`);

View File

@@ -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<MatrixClient>) {
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 });
});
});

View File

@@ -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" })

View File

@@ -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 {}

View File

@@ -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<EmptyReturnBookingUnitRow> & { unitId: string; containerNumber: string },
): EmptyReturnBookingUnitRow => ({
...booking,
containerSize: '40ft',
containerType: '40FT',
returnId: null,
returnStatus: null,
...overrides,
});
describe('assembleEmptyReturnBookings', () => {
it('groups a bookings 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([]);
});
});

View File

@@ -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<string, EmptyReturnBookingRow>();
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);
}

View File

@@ -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' })

View File

@@ -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],

View File

@@ -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<EmptyReturnBookingRow[]> {
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;
}

View File

@@ -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<void> {
@@ -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);
}
}
}

View File

@@ -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)');
});
});

View File

@@ -0,0 +1,30 @@
import { TrainSchedule } from './entities/train-schedule.entity';
export type TrainRunSource = Pick<TrainSchedule, 'trainNumber' | 'voyageNumber'>;
/**
* 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;
}

View File

@@ -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),
);

View File

@@ -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' };

View File

@@ -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<void> {
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 },
});
}
}
}

View File

@@ -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/);
});
});

View File

@@ -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<string> {
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<string | null> {
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<NotifyInput> } = {},
): 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<void> {
@@ -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}.`,
);
}
}

View File

@@ -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<string>();
@@ -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

View File

@@ -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<string, string[]>,
) => {
const unassigned: Array<{ scheduleId: string; bookingId: string }> = [];
const svc = Object.create(TrainSchedulingService.prototype) as {
unloadedBoarderIdsAtYard(
scheduleId: string,
yardId: string,
): Promise<string[]>;
unassignBooking(
scheduleId: string,
bookingId: string,
userId?: string,
): Promise<void>;
};
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<unknown>;
};
assertNoPartiallyLoadedBookings(
schedule: unknown,
boardingYardId: string,
context: { action: string; yardLabel?: string },
): Promise<void>;
};
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<string | null>,
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']);
});
});

View File

@@ -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:

View File

@@ -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[];
}

View File

@@ -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',

View File

@@ -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('<span>Total containers</span><strong>1</strong>');
});
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('<span>Wagons</span><strong>1</strong>');
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
});
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('<span>Total containers</span><strong>1</strong>');
});
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('<span>Wagons</span><strong>1</strong>');
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
});
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(() => {

View File

@@ -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<Array<{ allocationId: string; wagonId: string; wagonNumber: string; yardId: string | null; trainId: string | null }>> {
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<void> {
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<string[]> {
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

Some files were not shown because too many files have changed in this diff Show More