Merge pull request #1485 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-09-03 16:11:19 +03:00
committed by GitHub
293 changed files with 31278 additions and 2044 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

@@ -35,6 +35,7 @@ import { ConsignmentsModule } from "./modules/consignments/consignments.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { TruckTypesModule } from "./modules/truck-types/truck-types.module";
import { TransitAgentsModule } from "./modules/transit-agents/transit-agents.module";
import { TransitAssignmentsModule } from "./modules/transit-assignments/transit-assignments.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
@@ -95,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
import { EimsModule } from "./modules/eims/eims.module";
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module";
import { WagonsModule } from "./modules/wagons/wagons.module";
import { ContainersModule } from "./modules/container-management/containers.module";
import { CargoesModule } from "./modules/cargoes/cargoes.module";
@@ -115,6 +117,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module";
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
@@ -205,6 +208,7 @@ if (!process.env.APPLICATION_NAME) {
LocomotivesModule,
TruckTypesModule,
TransitAgentsModule,
TransitAssignmentsModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
@@ -256,11 +260,13 @@ if (!process.env.APPLICATION_NAME) {
FirstMileModule,
LastMileModule,
LastMileRequestsModule,
EmptyReturnRequestsModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,
EimsModule,
FleetHistoryModule,
WagonHistoryModule,
AiModule,
AuditModule,
ChatModule,

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

@@ -4,25 +4,29 @@ import {
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
} from "class-validator";
import {
isValidPhoneNumber,
parsePhoneNumberFromString,
} from "libphonenumber-js";
/**
* Country-aware phone validation. The value is expected as a full international
* number (E.164, e.g. "+251911223344"), so the country is derived from the
* value itself — no separate country field needed.
* number (E.164, e.g. "+25377834567" for Djibouti or "+251911223344" for
* Ethiopia), so the country is derived from the value itself — no separate
* country field needed.
*/
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
@ValidatorConstraint({ name: "IsValidPhone", async: false })
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
if (value === undefined || value === null || value === '') return true;
if (typeof value !== 'string') return false;
if (value === undefined || value === null || value === "") return true;
if (typeof value !== "string") return false;
return isValidPhoneNumber(value);
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
return `${args.property} must be a complete international phone number (E.164, e.g. +25377834567 or +251911223344)`;
}
}
@@ -53,7 +57,7 @@ export function IsValidPhone(validationOptions?: ValidationOptions) {
export function normalizeE164(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined || value === null || value === '') return value;
const parsed = parsePhoneNumberFromString(value, 'ET');
if (value === undefined || value === null || value === "") return value;
const parsed = parsePhoneNumberFromString(value, "ET");
return parsed?.isValid() ? parsed.number : value.trim();
}

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,55 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Give a transit agent a portal login.
*
* Every column is NULLABLE and nothing is backfilled: production already holds
* transit agents that exist only as a GL-assignable roster entry, and they must
* keep working untouched. An agent gains an account when staff invite it — at
* which point `user_id` is filled in — so "has a login" is exactly
* `user_id IS NOT NULL`, and the assignment flow never has to care.
*
* The unique indexes are partial (`WHERE ... IS NOT NULL`) because Postgres
* treats NULLs as distinct in a plain unique index only per-row; being explicit
* documents that many account-less agents are expected to coexist.
*/
export class TransitAgentAccount3790000000000 implements MigrationInterface {
name = "TransitAgentAccount3790000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.transit_agents
ADD COLUMN IF NOT EXISTS user_id uuid,
ADD COLUMN IF NOT EXISTS email varchar(150),
ADD COLUMN IF NOT EXISTS phone_number varchar(30)`,
);
// One IAM account can back at most one transit agent — otherwise a single
// login would resolve to two agents in `findByUserId`.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_user_id
ON freight.transit_agents (user_id)
WHERE user_id IS NOT NULL AND deleted_at IS NULL`,
);
// Case-insensitive, matching how the repository checks for duplicates.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_agents_email
ON freight.transit_agents (lower(email))
WHERE email IS NOT NULL AND deleted_at IS NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.ux_transit_agents_email`,
);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.ux_transit_agents_user_id`,
);
await queryRunner.query(
`ALTER TABLE freight.transit_agents
DROP COLUMN IF EXISTS phone_number,
DROP COLUMN IF EXISTS email,
DROP COLUMN IF EXISTS user_id`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagon footprint pinned for cancellation pricing. `wagons_required` is a LIVE
* scheduling field — unassign clears it to NULL — so a paid booking pulled off
* a train had nothing left to price a cancellation fee or credit against
* ("This booking has no wagon requirement to cancel from."). This column is
* stamped once, at first allocation, and never cleared: cancellation reads it
* (falling back to a computed count for bookings never allocated).
*/
export class BookingCancellationWagons3800000000000 implements MigrationInterface {
name = 'BookingCancellationWagons3800000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS cancellation_wagons numeric(6,2)
`);
// Backfill the bookings that still carry a live stamp.
await queryRunner.query(`
UPDATE freight.bookings
SET cancellation_wagons = wagons_required
WHERE cancellation_wagons IS NULL
AND wagons_required IS NOT NULL
AND wagons_required > 0
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS cancellation_wagons
`);
}
}

View File

@@ -0,0 +1,72 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Transit assignments — one row per (booking × transit agent), so an agent
* handles many bookings.
*
* Deliberately NOT the existing transit-assignee handshake on bookings
* (`/bookings/:id/clearance/transit-assignee/...`, which stores its answer on
* the booking itself): that is a pre-declaration agreement between GL Ethiopia
* and GL Djibouti about WHO will handle customs. This is the work record —
* status, timings and documents — and nothing here reads or writes that flow.
*
* There is no duration column on purpose. The time taken after the train
* arrives is `finished_at bookings.arrived_at`, and both halves already
* exist; storing the difference would be a third source of truth that goes
* stale the moment either timestamp is corrected. It is computed on read.
*
* Documents hang off `freight.files` with `resource = 'transit_assignments'`
* and `resource_id = transit_assignments.id`. That table already carries the
* MinIO object, the upload time (`created_at`), the uploader, the edit time
* (`updated_at`) and the supersede history, so no file table is added here.
*/
export class TransitAssignments3810000000000 implements MigrationInterface {
name = "TransitAssignments3810000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.transit_assignments (
id uuid NOT NULL DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL,
transit_agent_id uuid NOT NULL,
status varchar(32) NOT NULL DEFAULT 'NOT_STARTED',
started_at timestamptz,
finished_at timestamptz,
assigned_by_user_id uuid,
assigned_at timestamptz NOT NULL DEFAULT now(),
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT pk_transit_assignments PRIMARY KEY (id),
CONSTRAINT fk_transit_assignments_booking
FOREIGN KEY (booking_id) REFERENCES freight.bookings (id),
CONSTRAINT fk_transit_assignments_agent
FOREIGN KEY (transit_agent_id) REFERENCES freight.transit_agents (id)
)
`);
// One live assignment per (booking, agent). Partial so a soft-deleted row
// never blocks re-assigning the same agent to the same booking later.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_transit_assignments_booking_agent
ON freight.transit_assignments (booking_id, transit_agent_id)
WHERE deleted_at IS NULL
`);
// The two list directions: a booking's assignments, and an agent's workload.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_transit_assignments_booking
ON freight.transit_assignments (booking_id) WHERE deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS ix_transit_assignments_agent_status
ON freight.transit_assignments (transit_agent_id, status)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.transit_assignments`);
}
}

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

@@ -17,6 +17,7 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
wagons: number;
weightTons: number;
quantities: { bulkTons?: number };
totalWagons: number;
}>;
};
const booking = {
@@ -29,7 +30,39 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
it('cancels every wagon with the exact total tonnage', async () => {
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
expect(cut).toEqual({
wagons: 4,
weightTons: 250.5,
quantities: { bulkTons: 250.5 },
totalWagons: 4,
});
});
/**
* Unassigning a paid booking from a train clears `wagonsRequired` to NULL, so
* cancellation used to reject it outright ("no wagon requirement to cancel
* from"). The pinned `cancellationWagons`, stamped at first allocation, keeps
* the footprint through the unassign.
*/
it('falls back to the pinned cancellation footprint when wagonsRequired is cleared', async () => {
const unassigned = { ...booking, wagonsRequired: null, cancellationWagons: 4 };
const cut = await svc.resolveRequestedCut(unassigned, { wagons: 4 });
expect(cut.wagons).toBe(4);
expect(cut.totalWagons).toBe(4);
expect(cut.weightTons).toBe(250.5);
});
/** NUMBER_OF_WAGONS bulk never allocated: the customer's pinned count sizes it. */
it('sizes a never-allocated NUMBER_OF_WAGONS booking from bulkRequestedWagons', async () => {
const fresh = {
...booking,
wagonsRequired: null,
cancellationWagons: null,
bulkRequestedWagons: 3,
};
const cut = await svc.resolveRequestedCut(fresh, { wagons: 3 });
expect(cut.totalWagons).toBe(3);
expect(cut.weightTons).toBe(250.5);
});
it('rejects more wagons than the booking has', async () => {
@@ -91,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
};
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
// An odd credit always leaves a half-empty wagon, so GL must name who fills
// it — the rebook is refused rather than shipping a half-empty wagon.
await expect(
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
).rejects.toThrow(/pick a consolidation partner/i);
@@ -110,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
}),
).rejects.toThrow(/already shares a wagon/i);
});
/**
* An EXPIRED partner has no pay window left, so pairing the PAID rebook
* straight onto it strands the shared wagon: neither half can board and
* nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a
* fresh booking that carries its own invoice.
*/
it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => {
const dead = {
id: 'p1',
reference: 'BK-2026-001114',
status: 'EXPIRED',
contractId: 'c1',
consolidationPartnerId: null,
paymentCurrency: 'USD',
originYardId: 'y1',
destinationYardId: 'y2',
tradeDirection: 'IMPORT',
scheduledDate: '2026-09-01',
bookingContainers: [
{
containerSize: '20ft',
quantity: 1,
hazardousQuantity: 0,
reeferQuantity: 0,
containerType: { sizeFt: 20 },
units: [
{
containerNumber: 'PCONT0',
sealNumber: null,
vgmTons: 9,
isHazardous: false,
isReefer: false,
},
],
},
],
};
const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' };
const svc = makeSvc(dead) as Record<string, unknown>;
let createdUnderContract: string | null = null;
let pairedWith: string | null = null;
(svc as { bookingsRepository: Record<string, unknown> }).bookingsRepository = {
findById: async () => source,
findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead),
hasSpentCancellationCredit: async () => false,
};
(svc as { contractBooking: unknown }).contractBooking = {
createUnderContract: async (contractId: string) => {
createdUnderContract = contractId;
return { booking: { id: 'p1-clone' } };
},
};
(svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined;
const cloned = await (
svc as unknown as {
cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>;
}
).cloneDeadPartner(dead, '2026-09-01');
// The dead booking is left dead; the clone is what gets paired and paid.
expect(cloned.id).toBe('p1-clone');
expect(cloned.reference).toBe('BK-2026-001116');
expect(createdUnderContract).toBe('c1');
expect(pairedWith).toBeNull();
});
});
/**
@@ -199,3 +302,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
expect(dto.requestedWagons).toBeUndefined();
});
});
/**
* The cancellation fee is paid BEFORE the credit is redeemed.
*
* An at-loading cut applies immediately and opens the credit while its fee
* invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was
* settled. Without the gate the customer rebooks the same wagons and the
* cancellation fee is simply never collected. EDR-fault cuts carry no fee and
* must stay freely rebookable — partial or whole, container or bulk.
*/
describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => {
const source = {
id: 'b1',
contractId: 'c1',
paymentCurrency: 'ETB',
originYardId: 'y1',
destinationYardId: 'y2',
tradeDirection: 'IMPORT',
};
const makeSvc = (row: Record<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

@@ -23,6 +23,8 @@ import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { requestedBulkWagons } from '../train-scheduling/train-capacity.util';
import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -51,6 +53,8 @@ import {
CancelledUnitSnapshot,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from './entities/booking-wagon-cancellation.entity';
import { WagonEventType } from '@edr/types';
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
@@ -74,6 +78,8 @@ interface RequestedCut {
wagons: number;
weightTons: number;
quantities: CancelledQuantities;
/** The booking's whole wagon footprint the cut came out of — credit divides by it. */
totalWagons: number;
}
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
@@ -130,6 +136,7 @@ export class BookingWagonCancellationService {
private readonly firstMile: FirstMileService,
private readonly inbox: NotificationInboxService,
private readonly events: EventEmitter2,
private readonly wagonHistory: WagonHistoryService,
) {}
// ── T1: request ────────────────────────────────────────────────────────────
@@ -165,7 +172,7 @@ export class BookingWagonCancellationService {
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
creditAmount: round2(Number(booking.totalAmount ?? 0)),
};
}
this.assertCutSparesSharedWagon(cut);
@@ -177,7 +184,7 @@ export class BookingWagonCancellationService {
feePerWagon: fee.perWagon,
feeAmount: fee.amount,
feeCurrency: fee.currency,
creditAmount: this.creditFor(booking, cut.wagons),
creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons),
};
}
@@ -218,7 +225,7 @@ export class BookingWagonCancellationService {
: await this.resolveRequestedCut(booking, dto);
const fee = await this.priceFee(booking, cut);
const feeAmount = fee.amount;
const creditAmount = this.creditFor(booking, cut.wagons);
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
const row = await this.repo.create({
bookingId,
@@ -318,7 +325,7 @@ export class BookingWagonCancellationService {
const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({
where: { bookingId: row.bookingId },
});
if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) {
if (rows < Math.round(await this.wagonFootprint(booking))) {
throw new ConflictException(
'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.',
);
@@ -363,7 +370,7 @@ export class BookingWagonCancellationService {
const row = await this.openConsolidationBreak(
booking,
'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
round2(Number(booking.totalAmount ?? 0)),
reason ?? 'Consolidated pair cancelled',
userId,
);
@@ -371,7 +378,7 @@ export class BookingWagonCancellationService {
await this.openConsolidationBreak(
partner,
'floor',
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
round2(Number(partner.totalAmount ?? 0)),
`Cancelled with its consolidation partner ${booking.reference}`,
userId,
);
@@ -540,7 +547,8 @@ export class BookingWagonCancellationService {
} as RequestWagonCancellationDto);
}
return this.resolveRequestedCut(booking, {
wagons: Number(booking.wagonsRequired ?? 0),
// Footprint, not the live wagonsRequired: unassign clears that to NULL.
wagons: await this.wagonFootprint(booking),
} as RequestWagonCancellationDto);
}
@@ -568,7 +576,7 @@ export class BookingWagonCancellationService {
const row = await this.openConsolidationBreak(
booking,
'ceil',
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
round2(Number(booking.totalAmount ?? 0)),
'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies',
);
await this.dataSource.getRepository(Booking).update(booking.id, {
@@ -729,9 +737,10 @@ export class BookingWagonCancellationService {
// Whole-booking cut: nothing is left to ship, so the booking ends
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
// train. The credit row still points at it for T3.
const wagonsLeft = round2(
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
);
// Off the pinned footprint, not the live wagonsRequired — unassign
// clears that to NULL, which read as a full cut on any partial cancel.
const footprint = await this.wagonFootprint(booking);
const wagonsLeft = round2(footprint - Number(row.wagonsCancelled));
const isFull = wagonsLeft <= 0;
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
@@ -745,6 +754,9 @@ export class BookingWagonCancellationService {
: null;
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: Math.max(0, wagonsLeft),
// Keep the cancellation footprint in step, so a second partial cancel
// prices against what is actually left, not the original booking.
cancellationWagons: Math.max(0, wagonsLeft),
...(requestedWagonsLeft !== null
? { bulkRequestedWagons: requestedWagonsLeft }
: {}),
@@ -853,14 +865,37 @@ export class BookingWagonCancellationService {
);
}
// Staff may cut a SUBSET of the never-loaded wagons (picked in the loading
// modal) instead of the whole remainder. Anything already LOADED is
// rejected rather than silently dropped: the operator believes they are
// cancelling that wagon, and it is on the train.
let target = remaining;
if (dto.wagonAllocationIds?.length) {
const wanted = new Set(dto.wagonAllocationIds);
const known = new Set(allocations.map((a) => a.id));
const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id));
if (unknown.length) {
throw new BadRequestException(
'Some selected wagons are not allocated to this booking on this schedule.',
);
}
const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a));
if (loaded.length) {
throw new BadRequestException(
`${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`,
);
}
target = remaining.filter((a) => wanted.has(a.id));
}
const cut = await this.resolveRequestedCut(booking, {
wagonAllocationIds: remaining.map((r) => r.id),
wagonAllocationIds: target.map((r) => r.id),
} as RequestWagonCancellationDto);
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
const edrFault = !!dto.edrFault;
const fee = edrFault ? null : await this.priceFee(booking, cut);
const creditAmount = this.creditFor(booking, cut.wagons);
const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons);
const row = await this.repo.create({
bookingId,
@@ -971,6 +1006,18 @@ export class BookingWagonCancellationService {
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
);
}
// Customer-fault fee settles BEFORE the credit is redeemed. An at-loading
// cut applies immediately and opens the credit while its invoice stays
// open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without
// this the customer rebooks the wagons and never pays the cancellation
// fee the notice already promised. EDR fault carries no fee and is
// unaffected; onFeePaid stamps feePaidAt and the gate opens by itself.
if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) {
throw new BadRequestException(
`Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` +
`${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`,
);
}
const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
if (!source.contractId) {
@@ -988,6 +1035,9 @@ export class BookingWagonCancellationService {
let partner: Booking | null = null;
if (oddFt20) {
createDto.skipAutoConsolidation = true;
// An odd credit always leaves a half-empty wagon, so GL names who fills
// it. The candidate list is wide enough (any unpaired, unspent booking on
// the day) that a partner is expected to exist.
if (!dto.partnerBookingId) {
throw new BadRequestException(
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
@@ -998,6 +1048,15 @@ export class BookingWagonCancellationService {
dto.partnerBookingId,
dto.scheduledDate,
);
// A dead partner cannot be paid where it stands — its cargo moves to a
// fresh booking that can carry its own invoice and pay window.
if (['EXPIRED', 'CANCELLED'].includes(partner.status)) {
partner = await this.cloneDeadPartner(
partner,
dto.scheduledDate,
userId,
);
}
}
const created = await this.contractBooking.createUnderContract(
source.contractId,
@@ -1032,6 +1091,15 @@ export class BookingWagonCancellationService {
);
}
if (partner) {
// Corrections GL made to the partner's own containers while pairing —
// scoped to that booking by the repository, so a stray id cannot touch
// another booking's cargo.
if (dto.partnerUnits?.length) {
await this.bookingsRepository.patchContainerUnitsForBooking(
partner.id,
dto.partnerUnits,
);
}
// Consolidated rebook: never allocate the half-wagon booking alone. It
// rides PAID and the batch engine settles the pair atomically once the
// partner's own invoice is paid.
@@ -1083,6 +1151,13 @@ export class BookingWagonCancellationService {
status: string;
scheduledDate: string | null;
ft20Quantity: number;
units: Array<{
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string | null;
vgmTons: number;
}>;
}>
> {
const row = await this.mustFind(cancellationId);
@@ -1103,6 +1178,18 @@ export class BookingWagonCancellationService {
ft20Quantity: (b.bookingContainers ?? [])
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
// Editable while pairing — GL corrects these on the rebook form.
units: (b.bookingContainers ?? []).flatMap((line) =>
(line.units ?? []).map((u) => ({
id: u.id,
containerSize: line.containerType?.sizeFt
? `${line.containerType.sizeFt}ft`
: '',
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons ?? 0),
})),
),
}));
}
@@ -1121,11 +1208,29 @@ export class BookingWagonCancellationService {
`Booking ${partner.reference} already shares a wagon with another booking.`,
);
}
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
// Mirrors findRebookConsolidationCandidates: a partner need not be a live
// committed shipment. One that lost its slot or was called off still has
// cargo to move, and the rebooked wagon is how it moves.
if (
![
'SUBMITTED',
'PENDING_CONSOLIDATION',
'CLEARANCE_READY',
'OPERATION_CHANGES_REQUESTED',
'EXPIRED',
'CANCELLED',
].includes(partner.status)
) {
throw new BadRequestException(
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
);
}
// A booking whose own credit was already rebooked elsewhere is spent.
if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) {
throw new BadRequestException(
`Booking ${partner.reference} has already been rebooked from its cancellation credit.`,
);
}
if (
partner.originYardId !== source.originYardId ||
partner.destinationYardId !== source.destinationYardId ||
@@ -1153,6 +1258,74 @@ export class BookingWagonCancellationService {
return partner;
}
/**
* A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no
* longer be paid: its pay window is gone and finalizing it issues nothing a
* customer can settle, so pairing the PAID rebook with it strands the shared
* wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook,
* no payment_deadline — neither half could ever board). So the cargo is
* cloned into a fresh booking under the same contract, which finalizes
* normally into its own invoice and pay window; the dead booking stays dead.
*/
private async cloneDeadPartner(
partner: Booking,
scheduledDate: string,
userId?: string,
): Promise<Booking> {
if (!partner.contractId) {
throw new BadRequestException(
`Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`,
);
}
const dto: CreateBookingUnderContractDto = {
scheduledDate,
paymentCurrency: partner.paymentCurrency ?? undefined,
// GL already chose this pairing — the auto-matcher must not re-home the
// clone behind their back (same reasoning as the rebooked side).
skipAutoConsolidation: true,
containers: (partner.bookingContainers ?? []).map((line) => {
const units = line.units ?? [];
return {
containerSize: line.containerSize ?? undefined,
quantity: Number(line.quantity),
units: units.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? '',
vgmTons: u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
})),
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
reeferQuantity: Number(line.reeferQuantity ?? 0),
};
}) as CreateBookingUnderContractDto['containers'],
};
const created = await this.contractBooking.createUnderContract(
partner.contractId,
dto,
{ id: userId ?? partner.createdByUserId ?? undefined },
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
// The dead partner's own contract may have lapsed while it sat expired;
// its cargo is still the cargo GL picked to fill the shared wagon.
{ allowExpiredContract: true },
);
const clone = await this.bookingsRepository.findByIdWithFiles(
created.booking.id,
);
if (!clone) {
throw new NotFoundException(
`Replacement booking for ${partner.reference} could not be loaded.`,
);
}
this.notifyCustomer(
partner,
'Replacement booking created',
`${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`,
clone.id,
);
return clone;
}
/**
* Link the rebooked (already PAID) booking with the GL-picked partner. A
* parked partner is resumed the way pairConsolidation would resume it —
@@ -1253,7 +1426,7 @@ export class BookingWagonCancellationService {
booking: Booking,
dto: RequestWagonCancellationDto,
): Promise<RequestedCut> {
const totalWagons = Number(booking.wagonsRequired ?? 0);
const totalWagons = await this.wagonFootprint(booking);
if (totalWagons <= 0) {
throw new BadRequestException('This booking has no wagon requirement to cancel from.');
}
@@ -1335,6 +1508,7 @@ export class BookingWagonCancellationService {
weightTons: weightShare,
// Bookings without unit records fall back to the T2 LIFO trim.
quantities: { bySize, ...(units.length === requested ? { units } : {}) },
totalWagons,
};
}
@@ -1360,7 +1534,7 @@ export class BookingWagonCancellationService {
if (tons <= 0) {
throw new BadRequestException('The requested cut is too small to release cargo.');
}
return { wagons, weightTons: tons, quantities: { bulkTons: tons } };
return { wagons, weightTons: tons, quantities: { bulkTons: tons }, totalWagons };
}
/**
@@ -1416,6 +1590,7 @@ export class BookingWagonCancellationService {
wagons,
weightTons: tons,
quantities: { bulkTons: tons, allocationIds },
totalWagons,
};
}
@@ -1460,12 +1635,54 @@ export class BookingWagonCancellationService {
wagons,
weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)),
quantities: { bySize, units, allocationIds },
totalWagons,
};
}
/**
* The booking's wagon footprint for cancellation pricing.
*
* `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so
* a paid booking pulled off a train read 0 wagons and could not be cancelled
* at all. `cancellationWagons` is stamped once at first allocation and never
* cleared — read it first. A booking never allocated has neither, so size it
* from the cargo the same way the scheduler would: TEU geometry for
* containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage
* ÷ wagon capacity for PER_TON bulk.
*/
private async wagonFootprint(booking: Booking): Promise<number> {
const pinned = Number(booking.cancellationWagons ?? 0);
if (pinned > 0) return round2(pinned);
const stored = Number(booking.wagonsRequired ?? 0);
if (stored > 0) return round2(stored);
const requested = requestedBulkWagons(booking);
if (requested > 0) return requested;
// Cargo relations drive the sizing — reload when the caller passed a bare
// booking (findById does not always hydrate them).
const full =
booking.bookingContainers || booking.cargoType
? booking
: ((await this.dataSource.getRepository(Booking).findOne({
where: { id: booking.id },
relations: {
bookingContainers: { containerType: true },
cargoType: { wagonTypes: true },
},
})) ?? booking);
const capacities = (full.cargoType?.wagonTypes ?? [])
.map((wt) => Number(wt.capacityTons))
.filter((c) => c > 0);
const bulkCapacity =
full.freightType === 'BULK' && capacities.length
? Math.max(...capacities)
: undefined;
return round2(wagonsRequiredForBooking(full, bulkCapacity));
}
/** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */
private creditFor(booking: Booking, wagons: number): number {
const totalWagons = Number(booking.wagonsRequired ?? 0);
private creditFor(booking: Booking, wagons: number, totalWagons: number): number {
if (totalWagons <= 0) return 0;
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
}
@@ -1759,6 +1976,7 @@ export class BookingWagonCancellationService {
.getRepository(WagonAllocationContainerItem)
.delete(cut.map((i) => i.id));
if (cut.length === items.length) {
await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking');
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
} else {
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
@@ -1805,9 +2023,66 @@ export class BookingWagonCancellationService {
await manager
.getRepository(WagonAllocationBulkLoad)
.delete({ wagonBookingAllocationId: In(ids) });
await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking');
await manager.getRepository(WagonBookingAllocation).delete(ids);
}
/**
* BOOKING_CANCELLED history row for every physical wagon behind the released
* allocations — resolved through the slot BEFORE the allocation rows go, one
* query for the whole batch. Slots with no wagon pinned yet leave no row.
*/
private async recordAllocationRelease(
manager: EntityManager,
allocationIds: string[],
bookingId: string,
reason: string,
): Promise<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";
@@ -597,6 +599,34 @@ export class BookingsController {
return this.bookingsService.wagonAllocations(id);
}
@Get(":id/wagons/export")
@MixedAudience(FREIGHT_PERMS.bookings.view)
@ApiOperation({
summary:
"Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)",
})
async wagonAllocationsExport(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
booking,
);
}
const { filename, buffer } =
await this.bookingsService.wagonAllocationsWorkbook(id);
res.setHeader(
"Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.send(buffer);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@@ -664,9 +694,11 @@ export class BookingsController {
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
// GL (createBooking) rebooks credits and must see the ledger for that.
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
@@ -783,12 +815,78 @@ export class BookingsController {
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
/**
* Scope a clearance READ that a transit agent may be making.
*
* Transit agents are portal accounts holding no permission and belonging to
* no company, so the audience guards admit them but the usual company-based
* ownership check would 404 every booking. This narrows them to the shipments
* assigned to them and leaves every other caller — staff and owning customers
* — on the path they already had. Purely widening: nothing that passed before
* starts failing here.
*/
private async assertTransitAgentScope(
bookingId: string,
user: TCurrentUser,
): Promise<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 +946,7 @@ export class BookingsController {
})
async bulkAddCustomerTrucks(
@Param("id", ParseUUIDPipe) id: string,
@Body() payload: { trucks: AddCustomerTruckDto[] },
@Body() payload: BulkCustomerTrucksDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
@@ -1128,6 +1226,9 @@ export class BookingsController {
}
@Get(":id/clearance")
// A transit agent is a portal account, so MixedAudience admits them without a
// permission; `assertTransitAgentScope` below narrows them to the shipments
// actually assigned to them.
@MixedAudience([
FREIGHT_PERMS.bookings.clearanceView,
FREIGHT_PERMS.bookings.reviewDocuments,
@@ -1136,7 +1237,11 @@ export class BookingsController {
summary:
"Document-clearance grid (required docs + upload + GL review status)",
})
getClearance(@Param("id", ParseUUIDPipe) id: string) {
async getClearance(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertTransitAgentScope(id, user);
return this.transitionService.getClearanceView(id);
}
@@ -1278,8 +1383,11 @@ export class BookingsController {
return { success: true };
}
// Was staff-only. Opened to the transit agent assigned to the shipment, who
// needs the clearance trail for the bookings they handle; every other portal
// account is still rejected by the scope check below.
@Get(":id/clearance/history")
@BookingStaff([
@MixedAudience([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@@ -1287,7 +1395,11 @@ export class BookingsController {
summary:
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
})
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
async getClearanceHistory(
@Param("id", ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
return this.clearanceEventService.list(id);
}
@@ -1310,6 +1422,12 @@ export class BookingsController {
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
if (isStaff) return this.clearanceChargeService.list(id);
// The transit agent handling this shipment sees the same customer-facing
// slice the customer does — charges actually sent, never the internal
// draft/billing view `list()` returns.
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
return this.clearanceChargeService.listForCustomer(id);
}
const booking = await this.bookingsService.findById(id);
await this.bookingsService.assertCustomerCanAccessBooking(
user?.id,
@@ -1777,8 +1895,10 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
// Djibouti-desk write, also filed by the transit agent assigned to this
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
@Post(":id/clearance/delivery-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingDeliveryOrder(
@@ -1788,6 +1908,7 @@ export class BookingsController {
@Body("doCollectedDate") doCollectedDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
files ?? [],
@@ -1798,7 +1919,7 @@ export class BookingsController {
}
@Post(":id/clearance/release-order")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingReleaseOrder(
@@ -1807,6 +1928,7 @@ export class BookingsController {
@Body("vesselDepartureDate") vesselDepartureDate: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
const result = await this.bookingClearanceService.uploadReleaseOrder(
id,
files ?? [],
@@ -1820,13 +1942,69 @@ export class BookingsController {
};
}
// Transit-agent arrival paperwork (export): gate pass and Djibouti T1 sets.
// Same audience rule as the DO/RO uploads above — the desk, or the agent
// assigned to this shipment.
@Post(":id/clearance/gate-pass-documents")
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingGatePassDocuments(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
return this.bookingClearanceService.uploadTransitArrivalDocuments(
id,
"gate_pass",
files ?? [],
resolveAuthUserId(user),
);
}
@Post(":id/clearance/djibouti-t1-documents")
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
async uploadBookingDjiboutiT1Documents(
@Param("id", ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
return this.bookingClearanceService.uploadTransitArrivalDocuments(
id,
"djibouti_t1",
files ?? [],
resolveAuthUserId(user),
);
}
@Delete(":id/clearance/transit-documents/:fileId")
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@HttpCode(204)
async removeBookingTransitDocument(
@Param("id", ParseUUIDPipe) id: string,
@Param("fileId", ParseUUIDPipe) fileId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
await this.bookingClearanceService.removeTransitArrivalDocument(
id,
fileId,
resolveAuthUserId(user),
);
}
@Post(":id/clearance/ro-amendment")
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
async requestBookingRoAmendment(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RoAmendmentDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertPortalClearanceAccess(id, user);
const booking = await this.bookingClearanceService.requestRoAmendment(
id,
dto.note,

View File

@@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
import { ExportsModule } from '../exports/exports.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
@@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
// CustomersModule,
RuleEngineModule,
FileUploadSettingsModule,
ExportsModule,
SignaturesModule,
registerExchangeModule(),
],

View File

@@ -34,10 +34,12 @@ import {
DocumentReviewStatus,
} from './entities/booking-document-review.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Booking } from './entities/booking.entity';
import {
BookingContractSignature,
@@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
* Bookings a GL operator may manually link to `booking` as its odd-20ft
* consolidation partner (Path B customs flow). Unlike
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
* quantity complement — this lists CANDIDATES for a human to choose from, so
* the filter is deliberately looser: any other customs booking on the same
* route/direction that is itself carrying an odd 20ft count. Two odd counts
* always sum to even, so any pick fills the shared wagon.
* quantity complement — this lists CANDIDATES for a human to choose from, but
* every row must still be a legal pick: another customs booking on the same
* route/direction, riding the same booking day, that is itself carrying an odd
* 20ft count. Two odd counts always sum to even, so any pick fills the shared
* wagon.
*
* Bare instances awaiting completion have no persisted containers yet, so the
* odd-count test runs on the requested container lines when they exist and the
* booking is offered as a candidate when they do not (GL enters its cargo on
* the split form).
* A booking whose cargo is not entered yet is NOT a candidate: with no
* container lines its 20ft count is unknown, so pairing with it cannot be
* shown to fill the wagon. Same rule as
* {@link findRebookConsolidationCandidates}.
*/
async findManualConsolidationCandidates(
booking: Booking,
limit = 50,
): Promise<Booking[]> {
const rows = await this.repository
const qb = this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
@@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
'OPERATION_CHANGES_REQUESTED',
'PENDING_CONSOLIDATION',
],
})
.orderBy('b.createdAt', 'ASC')
.take(limit)
.getMany();
});
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
// them on the split form) and stays a candidate; one that already carries
// cargo qualifies only when its 20ft total is odd.
// Same EAT booking day — the pair shares one physical wagon, so it must
// board one train. Applied only when this booking has a date of its own;
// without one there is no day to match against and route/direction stand
// alone, mirroring findComplementaryConsolidationPartner.
if (booking.scheduledDate) {
qb.andWhere(
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
{ bookingDate: booking.scheduledDate },
);
}
const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany();
// Odd-20ft test in memory. A booking with no container lines has an unknown
// 20ft count, so it cannot be shown to complete the wagon and is not
// offered.
return rows.filter((row) => {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
if (lines.length === 0) return false;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
@@ -396,10 +409,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
/**
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
* odd-20ft bookings on the same route/direction riding the requested day
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
* odd-20ft bookings on the same route/direction riding the requested day.
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
* GL picks who shares the rebooked wagon whatever the contract kind.
*
* The status set is deliberately wide. A partner here is not required to be a
* live, committed shipment — a booking that lost its slot (EXPIRED) or was
* cancelled still has cargo that GL can put back on a train, and pairing it
* with the rebooked credit is how both halves get moving again. What it must
* not be is already spoken for: a booking whose own cancellation credit has
* been rebooked elsewhere is excluded, as is one already paired.
*/
async findRebookConsolidationCandidates(
booking: Booking,
@@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
// Units come back so GL can correct the partner's container numbers,
// seals and VGMs while pairing.
.leftJoinAndSelect('bc.units', 'unit')
.leftJoinAndSelect('b.company', 'company')
.where('b.id != :bookingId', { bookingId: booking.id })
.andWhere('b.consolidationPartnerId IS NULL')
@@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
tradeDirection: booking.tradeDirection,
})
.andWhere('b.status IN (:...statuses)', {
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
statuses: [
'SUBMITTED',
'PENDING_CONSOLIDATION',
'CLEARANCE_READY',
'OPERATION_CHANGES_REQUESTED',
// Lost its slot or was called off — its cargo is still real and can
// ride the rebooked wagon.
'EXPIRED',
'CANCELLED',
],
})
// A cancelled booking whose own credit was already spent on a rebook is
// gone — pairing with it would hand the same cargo out twice.
.andWhere(
`NOT EXISTS (
SELECT 1 FROM freight.booking_wagon_cancellations c
WHERE c.booking_id = b.id
AND c.rebooked_booking_id IS NOT NULL
AND c.deleted_at IS NULL
)`,
)
// Same EAT booking day as the rebook — the pair shares one physical
// wagon, so it must board one train.
.andWhere(
@@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository<Booking> {
return new Set(rows.map((r) => r.bookingId));
}
/**
* Bookings among `bookingIds` that hold a redeemable wagon-cancellation
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth
* something, and it has not been spent on a rebook yet. Surfaced on the GL
* clearance queue so a paid-for credit is visibly rebookable from the list
* rather than only from the booking's own page.
*/
async findBookingsWithRedeemableCredit(
bookingIds: string[],
): Promise<Map<string, string>> {
if (bookingIds.length === 0) return new Map();
const rows = (await this.dataSource
.getRepository(BookingWagonCancellation)
.createQueryBuilder('c')
.select('c.booking_id', 'bookingId')
.addSelect('c.id', 'cancellationId')
.where('c.booking_id IN (:...bookingIds)', { bookingIds })
.andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' })
.andWhere('c.credit_amount > 0')
.andWhere('c.rebooked_booking_id IS NULL')
.andWhere('c.deleted_at IS NULL')
.getRawMany()) as Array<{ bookingId: string; cancellationId: string }>;
return new Map(rows.map((r) => [r.bookingId, r.cancellationId]));
}
/**
* Apply container-unit corrections (number / seal / VGM) to units that belong
* to `bookingId`. The ownership join is the point: a unit id from another
* booking silently matches nothing rather than editing a stranger's cargo.
* Sizes and quantities are never touched — only the identifying details.
* Returns how many units were actually updated.
*/
async patchContainerUnitsForBooking(
bookingId: string,
patches: Array<{
id: string;
containerNumber?: string;
sealNumber?: string;
vgmTons?: number;
}>,
): Promise<number> {
if (patches.length === 0) return 0;
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const owned = await unitRepo
.createQueryBuilder('u')
.innerJoin('u.bookingContainer', 'bc')
.where('bc.booking_id = :bookingId', { bookingId })
.andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) })
.select('u.id', 'id')
.getRawMany<{ id: string }>();
const ownedIds = new Set(owned.map((r) => r.id));
let updated = 0;
for (const patch of patches) {
if (!ownedIds.has(patch.id)) continue;
const set: Record<string, unknown> = {};
if (patch.containerNumber !== undefined)
set.containerNumber = patch.containerNumber;
if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber;
if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons;
if (Object.keys(set).length === 0) continue;
await unitRepo.update(patch.id, set as never);
updated += 1;
}
return updated;
}
/**
* Has this booking's own wagon-cancellation credit already been spent on a
* rebook? Such a booking must not be offered or accepted as a consolidation
* partner — its cargo has already moved to the rebooked booking.
*/
async hasSpentCancellationCredit(bookingId: string): Promise<boolean> {
const count = await this.dataSource
.getRepository(BookingWagonCancellation)
.createQueryBuilder('c')
.where('c.booking_id = :bookingId', { bookingId })
.andWhere('c.rebooked_booking_id IS NOT NULL')
.andWhere('c.deleted_at IS NULL')
.getCount();
return count > 0;
}
findDocumentReview(
bookingId: string,
settingCode: string,
@@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
select: { bookingId: true, trainScheduleId: true },
});
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
// The allocated train's own departure date — distinct from the customer's
// requested `booking.scheduledDate`. The list column shows this once a
// booking is on a train, so fetch it alongside the link ids.
const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[];
const schedules = scheduleIds.length
? await this.dataSource.getRepository(TrainSchedule).find({
where: { id: In(scheduleIds) },
select: {
id: true,
reference: true,
trainNumber: true,
status: true,
scheduledDepartureDate: true,
},
})
: [];
const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule]));
for (const item of items) {
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
scheduleByBooking.get(item.id) ?? null;
const scheduleId = scheduleByBooking.get(item.id) ?? null;
const enriched = item as Booking & {
trainScheduleId?: string | null;
trainScheduleReference?: string | null;
trainScheduleDepartureDate?: string | null;
};
enriched.trainScheduleId = scheduleId;
const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined;
enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null;
enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate
? new Date(schedule.scheduledDepartureDate).toISOString()
: null;
}
}
@@ -1786,6 +1939,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
Booking,
| 'schedulingStatus'
| 'wagonsRequired'
| 'cancellationWagons'
| 'scheduledAt'
| 'holdStartedAt'
| 'holdExpiresAt'

View File

@@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { TabularExportService } from '../exports/tabular-export.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
@@ -29,6 +30,11 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -99,6 +105,38 @@ export interface PaginatedBookings {
};
}
/** One container on an allocated wagon (raw SQL json_agg projection). */
export interface WagonAllocationContainer {
containerNumber: string | null;
sealNumber: string | null;
positionOnWagon: number | null;
grossWeightTons: number | null;
sizeFt: number | null;
}
/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */
export interface WagonAllocationRow {
allocationId: string;
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
wagonTypeCode: string | null;
/** numeric columns arrive as strings from pg. */
tareWeightTons: string | null;
capacityTons: string | null;
lengthMeters: string | null;
allocatedWeightTons: string | null;
loadType: string | null;
status: string | null;
trainNumber: string | null;
departureAt: string | Date | null;
originStation: string | null;
destinationStation: string | null;
bulkCargoDescription: string | null;
bulkQuantity: string | null;
containers: WagonAllocationContainer[];
}
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
interface CarriageAcceptanceWagonRow {
sequenceNo: number;
@@ -112,8 +150,13 @@ interface CarriageAcceptanceWagonRow {
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
departureStation: string | null;
arrivalStation: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
status: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
@@ -162,6 +205,7 @@ export class BookingsService {
private readonly bookingContractService: BookingContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly tabularExport: TabularExportService,
) {}
async assignCustomerTruck(
@@ -169,18 +213,23 @@ export class BookingsService {
dto: CustomerTruckAssignmentDto,
): Promise<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');
@@ -263,9 +312,13 @@ export class BookingsService {
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
* occupies. A booking is routinely loaded in parts (some containers go, the
* rest wait for the next train), so each row carries a Status of Loaded or
* Not loaded and the totals count only the loaded ones: the customer sees the
* whole plan on one page without the sheet overstating what EDR has taken.
*
* Handed to the customer when EDR accepts the cargo (export) and when the
* wagons are allocated before marshalling (import).
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
@@ -285,6 +338,9 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
COALESCE(by_.label, so.label) AS "departureStation",
COALESCE(ay.label, sd.label) AS "arrivalStation",
a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
@@ -296,13 +352,31 @@ export class BookingsService {
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
AND (
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
SELECT 1
FROM freight.booking_container_units received_unit
JOIN freight.booking_container received_line
ON received_line.id = received_unit.booking_container_id
AND received_line.deleted_at IS NULL
WHERE received_line.booking_id = a.booking_id
AND received_unit.container_number = ci.container_number
AND received_unit.received_to_port = true
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
AND received_unit.deleted_at IS NULL
)
)
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
by_.label, ay.label
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
ORDER BY tsw.sequence_no`,
[bookingId],
[bookingId, booking.tradeDirection, booking.freightType],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
@@ -337,17 +411,17 @@ export class BookingsService {
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
ORDER BY inv.created_at`,
`SELECT unit.vgm_tons AS "allocatedWeightTons",
unit.container_number AS "containerNumbers",
unit.seal_number AS "sealNumbers"
FROM freight.booking_container_units unit
JOIN freight.booking_container line
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
WHERE line.booking_id = $1
AND unit.deleted_at IS NULL
AND unit.received_to_port = true
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
ORDER BY unit.received_at, unit.container_number`,
[bookingId],
)
: [];
@@ -381,8 +455,12 @@ export class BookingsService {
departureAt: null,
marshalledAt: null,
arrivalAt: null,
departureStation: null,
arrivalStation: null,
containerNumbers: row.containerNumbers,
sealNumbers: row.sealNumbers ?? null,
// A received line has no allocation; it is cargo EDR already holds.
status: null,
}));
}
@@ -403,7 +481,7 @@ export class BookingsService {
* an array per wagon, bulk load description when the wagon carries bulk).
* Empty array until the booking has been allocated onto a train.
*/
async wagonAllocations(bookingId: string): Promise<unknown[]> {
async wagonAllocations(bookingId: string): Promise<WagonAllocationRow[]> {
return this.dataSource.query(
`SELECT a.id AS "allocationId",
tsw.sequence_no AS "sequenceNo",
@@ -457,6 +535,103 @@ export class BookingsService {
);
}
/**
* The Wagons tab's Excel export: the booking's customer identity in the KPI
* header, then one row per allocated wagon.
*
* Container numbers are flattened into a single cell rather than exploded
* into one row per container — the sheet is a wagon manifest, and a reader
* counting rows must get the wagon count.
*/
async wagonAllocationsWorkbook(
bookingId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
const wagons = await this.wagonAllocations(bookingId);
// Same precedence the booking list uses: a shipping line owns its bookings
// directly, a government booking names its institution, everyone else is
// the customer company.
// `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies),
// not a declared relation on the entity — hence the cast, matching that helper.
const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } })
.shippingLineCompany;
const customerName =
shippingLine?.name ??
(booking.isGovernment ? booking.governmentInstitution : null) ??
booking.company?.name ??
'—';
const rows = wagons.map((w) => ({
sequenceNo: w.sequenceNo,
wagonNumber: w.wagonNumber ?? '—',
wagonType: w.wagonType ?? '—',
loadType: w.loadType ?? '—',
status: w.status ?? '—',
tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons),
capacityTons: w.capacityTons === null ? null : Number(w.capacityTons),
allocatedWeightTons:
w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons),
lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters),
containerCount: w.containers?.length ?? 0,
containerNumbers:
(w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—',
sealNumbers:
(w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—',
bulkCargo: w.bulkCargoDescription ?? '—',
bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity),
trainNumber: w.trainNumber ?? '—',
departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—',
originStation: w.originStation ?? '—',
destinationStation: w.destinationStation ?? '—',
// Repeated on every row so the sheet survives being filtered, sorted or
// pasted into a combined workbook, where the header block is lost.
customerName,
bookingReference: booking.reference,
}));
const totalAllocated = rows.reduce(
(sum, r) => sum + (r.allocatedWeightTons ?? 0),
0,
);
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${booking.reference}`.slice(0, 31),
description: `Wagons allocated to booking ${booking.reference}${customerName}`,
label: 'booking:wagon-allocations',
kpis: [
{ label: 'Wagons', value: rows.length },
{ label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) },
{ label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'bookingReference', label: 'Booking', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
{ key: 'sequenceNo', label: 'Seq', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon number', type: 'string' },
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
{ key: 'loadType', label: 'Load type', type: 'string' },
{ key: 'status', label: 'Status', type: 'string' },
{ key: 'tareWeightTons', label: 'Tare', type: 'tons' },
{ key: 'capacityTons', label: 'Capacity', type: 'tons' },
{ key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' },
{ key: 'lengthMeters', label: 'Length (m)', type: 'number' },
{ key: 'containerCount', label: 'Containers', type: 'number' },
{ key: 'containerNumbers', label: 'Container numbers', type: 'string' },
{ key: 'sealNumbers', label: 'Seal numbers', type: 'string' },
{ key: 'bulkCargo', label: 'Bulk cargo', type: 'string' },
{ key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' },
{ key: 'trainNumber', label: 'Train', type: 'string' },
{ key: 'departureAt', label: 'Departure', type: 'date' },
{ key: 'originStation', label: 'Origin', type: 'string' },
{ key: 'destinationStation', label: 'Destination', type: 'string' },
],
rows,
});
return { filename: `wagons-${booking.reference}.xlsx`, buffer };
}
/**
* Split the booking amount across its wagons, proportional to allocated weight
* (equal shares when no weights are recorded). The last row absorbs the rounding
@@ -497,7 +672,17 @@ export class BookingsService {
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
// totals count only those: the sheet shows the whole plan, but must never
// total up cargo still sitting in the yard. A received-line sheet
// (pendingWagons) has no allocation status, and every line on it is cargo
// already accepted, so it counts in full.
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
const loadedWagons = wagons.filter(isLoaded);
const notLoadedCount = wagons.length - loadedWagons.length;
const totals = loadedWagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +692,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
const fullWagons = loadedWagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
@@ -520,11 +705,14 @@ export class BookingsService {
<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'}">${
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
@@ -535,23 +723,37 @@ export class BookingsService {
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
}</td>
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</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>
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
<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>
@@ -568,6 +770,8 @@ export class BookingsService {
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
.footer-summary .tile { background: #f8fafc; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
@@ -575,6 +779,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
.loaded { color: #0f766e; font-weight: 700; }
.pending { color: #b45309; font-weight: 700; }
tr.totals td { background: #f8fafc; font-weight: 700; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
@@ -618,6 +824,7 @@ export class BookingsService {
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th>Status</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
@@ -626,6 +833,7 @@ export class BookingsService {
${totalsRow}
</tbody>
</table>
${footer}
<div class="notice">
${
@@ -1988,6 +2196,65 @@ export class BookingsService {
}
}
/**
* True when `userId` is a transit agent currently assigned to this booking.
*
* Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that
* assertion guards ~29 call sites, including wagon cancellations, rebooking
* and customer-truck writes. A transit agent must reach the clearance READS
* for the shipments they handle and nothing else, so the two ownership rules
* stay separate and each caller opts in explicitly.
*
* Queried directly rather than through TransitAssignmentsService: that module
* imports BookingsModule, so injecting it here would close an import cycle.
*/
/** Is this portal account a transit agent at all? */
async isTransitAgent(userId: string | undefined): Promise<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

@@ -105,6 +105,31 @@ export class RebookContainerLineDto {
units!: RebookUnitDto[];
}
/** One edited container unit on the consolidation partner booking. */
export class PartnerUnitPatchDto {
@ApiProperty({ description: 'Id of the partner booking container unit being edited' })
@IsUUID()
id!: string;
@ApiPropertyOptional({ description: 'Container number' })
@IsOptional()
@IsString()
@MaxLength(64)
containerNumber?: string;
@ApiPropertyOptional({ description: 'Seal number' })
@IsOptional()
@IsString()
@MaxLength(64)
sealNumber?: string;
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
@IsOptional()
@IsNumber()
@Min(0)
vgmTons?: number;
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
@@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto {
@IsOptional()
@IsUUID()
partnerBookingId?: string;
@ApiPropertyOptional({
description:
'Corrections to the partner booking\'s own container units (number / seal ' +
'/ VGM). Only the units listed are touched; sizes and quantities are never ' +
'changed. Ignored unless partnerBookingId is set.',
type: [PartnerUnitPatchDto],
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PartnerUnitPatchDto)
partnerUnits?: PartnerUnitPatchDto[];
}
export class FilterWagonCancellationsDto {
@@ -183,6 +221,19 @@ export class CancelRemainingWagonsDto {
@IsUUID('4')
scheduleId!: string;
@ApiPropertyOptional({
description:
'Cancel only THESE never-loaded wagons (wagon_booking_allocation ids from ' +
'GET /bookings/:id/wagons). Omit to cancel the whole unloaded remainder. ' +
'Already-loaded wagons are rejected — they are riding.',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonAllocationIds?: string[];
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
@IsString()
@IsNotEmpty()

View File

@@ -543,6 +543,13 @@ export class Booking extends BaseEntity {
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
wagonsRequired?: number | null;
// Wagon footprint pinned for cancellation pricing. `wagonsRequired` above is
// a LIVE scheduling field that unassign clears; this one is stamped once at
// first allocation and never cleared, so a paid booking pulled off a train
// can still price its cancellation fee and credit.
@Column({ name: 'cancellation_wagons', type: 'numeric', precision: 6, scale: 2, nullable: true })
cancellationWagons?: number | null;
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
schedulingStatus!: string;

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

@@ -57,8 +57,10 @@ import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import {
AccountInfoResponse,
ShippingLineInfoResponseDto,
TransitAgentInfoResponseDto,
} from "./dto/account-info-response.dto";
import { ShippingLineCompaniesService } from "../shipping-lines/shipping-line-companies.service";
import { TransitAgentsService } from "../transit-agents/transit-agents.service";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
@@ -104,6 +106,7 @@ export class CompaniesController {
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
private readonly transitAgentsService: TransitAgentsService,
) { }
/**
@@ -133,10 +136,10 @@ export class CompaniesController {
async getInfo(
@CurrentUser() user: CurrentIamUser,
): Promise<AccountInfoResponse> {
// A shipping line has no company and no external profile, so the customer
// lookup below would 404. Checked first, and reported with an explicit
// `accountKind` so the portal can skip onboarding for shipping lines
// without inferring it from a missing company.
// Neither a shipping line nor a transit agent has a company or an external
// profile, so the customer lookup below would 404 for both. Checked first,
// and reported with an explicit `accountKind` so the portal can skip
// onboarding for them without inferring it from a missing company.
const shippingLine = await this.shippingLineCompaniesService.findByUserId(
user.id,
);
@@ -144,6 +147,11 @@ export class CompaniesController {
return new ShippingLineInfoResponseDto(shippingLine);
}
const transitAgent = await this.transitAgentsService.findByUserId(user.id);
if (transitAgent) {
return new TransitAgentInfoResponseDto(transitAgent);
}
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
const review = await this.companiesService.getOpenChangeRequestForCompany(

View File

@@ -18,6 +18,7 @@ import { CompanyChangeRequest } from "./entities/company-change-request.entity";
import { CompanyRevision } from "./entities/company-revision.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { ShippingLineCompaniesModule } from "../shipping-lines/shipping-line-companies.module";
import { TransitAgentsModule } from "../transit-agents/transit-agents.module";
import { CompanyProfileRepository } from "./company-profile.repository";
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
import { CompanyRevisionRepository } from "./company-revision.repository";
@@ -49,6 +50,10 @@ import { VerifaydaModule } from "../verifayda/verifayda.module";
// shipping-line session, which has no company row to look up. forwardRef
// because that module imports BillingModule, which imports this one.
forwardRef(() => ShippingLineCompaniesModule),
// `GET /companies/getInfo` resolves a transit-agent session before falling
// through to the customer lookup. TransitAgentsModule is a leaf here — it
// does not import CompaniesModule — so no forwardRef is needed.
TransitAgentsModule,
],
controllers: [CompaniesController],
providers: [

View File

@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ShippingLineCompany } from "../../shipping-lines/entities/shipping-line-company.entity";
import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity";
import { CompanyInfoResponseDto } from "./company-info-response.dto";
/**
@@ -9,10 +10,10 @@ import { CompanyInfoResponseDto } from "./company-info-response.dto";
* The portal keys its onboarding gate off this rather than off "is `company`
* missing?": a failed or slow company fetch also leaves `company` empty, and
* treating that as "no onboarding needed" would let customers skip onboarding
* whenever the request failed. A shipping line is identified positively, and
* anything else defaults to `customer`.
* whenever the request failed. A shipping line and a transit agent are each
* identified positively, and anything else defaults to `customer`.
*/
export type AccountKind = "customer" | "shipping_line";
export type AccountKind = "customer" | "shipping_line" | "transit_agent";
/** The signed-in shipping line. No company, no profile, no onboarding. */
export class ShippingLineInfoResponseDto {
@@ -61,6 +62,62 @@ export class ShippingLineInfoResponseDto {
}
}
/**
* The signed-in transit agent. Like a shipping line: no company, no profile, no
* onboarding — but a separate account kind because the two share nothing beyond
* that, and the portal shows each a different (much smaller) set of tabs.
*/
export class TransitAgentInfoResponseDto {
@ApiProperty({ enum: ["transit_agent"] })
accountKind: "transit_agent" = "transit_agent";
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiPropertyOptional()
email?: string | null;
@ApiPropertyOptional()
phoneNumber?: string | null;
@ApiProperty()
isActive: boolean;
@ApiProperty({
description: "Start of the agent's validity window (yyyy-MM-dd)",
})
validFrom: string;
@ApiProperty({
description: "End of the agent's validity window (yyyy-MM-dd)",
})
validTo: string;
/** Always null — see {@link ShippingLineInfoResponseDto.company}. */
@ApiProperty({ nullable: true })
company: null = null;
@ApiProperty({ nullable: true })
profile: null = null;
@ApiProperty({ nullable: true })
review: null = null;
constructor(entity: TransitAgent) {
this.id = entity.id;
this.name = entity.name;
this.email = entity.email ?? null;
this.phoneNumber = entity.phoneNumber ?? null;
this.isActive = entity.isActive;
this.validFrom = entity.validFrom;
this.validTo = entity.validTo;
}
}
export type AccountInfoResponse =
| (CompanyInfoResponseDto & { accountKind: "customer" })
| ShippingLineInfoResponseDto;
| ShippingLineInfoResponseDto
| TransitAgentInfoResponseDto;

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

@@ -43,6 +43,9 @@ function makeService(overrides?: {
findBookingsWithUnreviewedDocuments: jest
.fn()
.mockResolvedValue(new Set<string>()),
findBookingsWithRedeemableCredit: jest
.fn()
.mockResolvedValue(new Map<string, string>()),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
@@ -116,6 +119,7 @@ function makeService(overrides?: {
.fn()
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
{ ensureAssignment: jest.fn() } as never, // transit assignments
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents

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,
@@ -1252,6 +1347,17 @@ export class BookingClearanceService {
.hasDocumentsAwaitingReview = pending.has(b.id);
}
// A cancelled booking may still hold a paid-for wagon-cancellation credit.
// GL redeems it from this queue, so the row carries the cancellation id the
// rebook action needs.
const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit(
filtered.map((b) => b.id),
);
for (const b of filtered) {
(b as Booking & { rebookableCancellationId?: string | null })
.rebookableCancellationId = credits.get(b.id) ?? null;
}
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}

View File

@@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
{ quantity: 4, containerType: { sizeFt: 20 } },
],
},
// A bare instance has no cargo yet — GL enters it on the split form, so it
// stays a candidate.
// Cargo not entered yet — its 20ft count is unknown, so it cannot be
// shown to fill the wagon and is not offered.
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
];
@@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
rows.filter((row) => {
void booking;
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
if (lines.length === 0) return false;
const ft20 = lines
.filter((l) => Number(l.containerType?.sizeFt) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
@@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
});
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']);
expect(candidates[0].ft20Quantity).toBe(3);
expect(candidates[1].hasCargo).toBe(false);
expect(candidates[0].hasCargo).toBe(true);
});
});

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

@@ -176,6 +176,18 @@ export class ContractNotifierService {
this.inApp(c, 'Contract suspension lifted', msg);
}
/**
* Backoffice cancelled the contract. Terminal — the customer is told they may
* submit a new contract with the same details if they still need the service.
*/
cancelledByStaff(c: Contract, reason: string): void {
const msg =
`Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` +
`If you still need this service you can submit a new contract request with the same details.`;
void this.notifyContact(c, msg, 'CANCELLED');
this.inApp(c, 'Contract cancelled', msg);
}
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(

View File

@@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => {
]);
});
});
describe('contract base freight ignores shipping-line rates', () => {
it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => {
// Both LIVE on the contract's own lane: the line rate sorted first and won,
// so the contract quoted 32 USD/wagon instead of the standard 1690.
const breakdown = await service([
rate({
containerTypeId: CT20,
rateValue: 32,
rateUnit: 'PER_WAGON',
shippingLineCompanyId: 'line-1',
}),
rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }),
]).buildBreakdown(contract({}));
expect(breakdown.lineItems).toEqual([
expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }),
]);
});
it('blocks when the only rate on the lane belongs to a shipping line', async () => {
await expect(
service([
rate({
containerTypeId: CT20,
rateValue: 32,
shippingLineCompanyId: 'line-1',
}),
]).buildBreakdown(contract({})),
).rejects.toThrow(UnprocessableEntityException);
});
});

View File

@@ -85,7 +85,15 @@ export class ContractPricingService {
* commodity rate) — NO totals or quantities (doc §9.1).
*/
async buildBreakdown(contract: Contract): Promise<ContractPricingBreakdown> {
const liveRates = await this.ratesService.findLiveRates();
// Contracts belong to a customer company — there is no shipping-line
// contract (no shipping_line_company_id on the entity), so a contract may
// only ever price off the standard rates. Without this filter a line's
// negotiated rate on the same lane matched first and the contract froze it
// for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and
// 23 USD/container 40ft instead of the standard 1690 / 1676.
const liveRates = (await this.ratesService.findLiveRates()).filter(
(r) => !r.shippingLineCompanyId,
);
const currency = contract.paymentCurrency;
const isEtb = currency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;

View File

@@ -0,0 +1,113 @@
import { ContractTransitionService } from './contract-transition.service';
import type { Contract } from './entities/contract.entity';
/**
* Staff cancel is terminal, so the rules that matter are: it needs its own
* permission (suspend must NOT imply it), it refuses to strand live shipments,
* it works on a suspended contract, and it cannot be applied twice.
*/
describe('ContractTransitionService — staff cancel', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
status: 'CONTRACT_ACTIVE',
freightType: 'CONTAINER',
...over,
}) as Contract;
let current: Contract;
let repo: {
update: jest.Mock;
createReviewNote: jest.Mock;
countActiveBookings: jest.Mock;
};
let notifier: { cancelledByStaff: jest.Mock };
let service: ContractTransitionService;
const staff = {
permissions: [{ key: 'edr_freight_app:contracts:cancel' }],
};
beforeEach(() => {
current = contract();
repo = {
update: jest.fn().mockImplementation((_id: string, patch: object) => {
current = { ...current, ...patch } as Contract;
return Promise.resolve(current);
}),
createReviewNote: jest.fn().mockResolvedValue(undefined),
countActiveBookings: jest.fn().mockResolvedValue(0),
};
notifier = { cancelledByStaff: jest.fn() };
service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsRepository: repo,
contractsService: { findById: () => Promise.resolve(current) },
notifier,
});
});
it('cancels, records the reason as a staff note, and notifies the customer', async () => {
await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith('c-1', {
status: 'CANCELLED',
statusBeforeSuspension: null,
});
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
'Duplicate request',
'CANCELLATION',
'staff-1',
'STAFF',
);
expect(notifier.cancelledByStaff).toHaveBeenCalled();
});
it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => {
current = contract({
status: 'SUSPENDED',
statusBeforeSuspension: 'CONTRACT_ACTIVE',
} as Partial<Contract>);
await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith('c-1', {
status: 'CANCELLED',
statusBeforeSuspension: null,
});
});
it('refuses while a shipment is still running', async () => {
repo.countActiveBookings.mockResolvedValue(2);
await expect(
service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never),
).rejects.toThrow('2 active shipments');
expect(repo.update).not.toHaveBeenCalled();
});
it('refuses to cancel an already-terminal contract', async () => {
current = contract({ status: 'CANCELLED' });
await expect(
service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never),
).rejects.toThrow(/already cancelled/i);
expect(repo.update).not.toHaveBeenCalled();
});
it('rejects a user holding only the suspend key — cancel is a separate permission', async () => {
const suspender = {
permissions: [{ key: 'edr_freight_app:contracts:suspend' }],
};
await expect(
service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never),
).rejects.toThrow();
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -1452,6 +1452,54 @@ export class ContractTransitionService {
return updated;
}
/**
* Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one
* with the same parameters can be submitted afterwards (references are minted
* per contract, so nothing about the old row blocks the new one).
*
* Cancellable from ANY non-terminal status, including SUSPENDED: a frozen
* contract is exactly the one staff most often need to kill outright.
*/
async cancelByStaff(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel);
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
throw new ConflictException(
`Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`,
);
}
// Same guard as the customer path: live shipments must be settled first,
// otherwise cancelling the contract orphans cargo already in motion.
const active = await this.contractsRepository.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
`This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` +
'Cancel or complete them before cancelling the contract.',
);
}
await this.contractsRepository.createReviewNote(
contractId,
reason,
'CANCELLATION',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: 'CANCELLED',
statusBeforeSuspension: null,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.cancelledByStaff(updated, reason);
return updated;
}
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -4,6 +4,7 @@ import {
Delete,
Get,
HttpCode,
NotFoundException,
Param,
ParseUUIDPipe,
Patch,
@@ -72,6 +73,7 @@ import {
RequestChangesDto,
ResumeContractDto,
SuspendContractDto,
CancelContractByStaffDto,
} from './dto/approve-step.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
@@ -552,6 +554,25 @@ export class ContractsController {
);
}
@Post(':id/staff/cancel')
@BookingStaff(FREIGHT_PERMS.contracts.cancel)
@ApiOperation({
summary:
'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)',
})
cancelByStaff(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelContractByStaffDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.cancelByStaff(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'Approve one approval step in sequence' })
@@ -1339,19 +1360,35 @@ export class ContractsController {
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
}
// Also filed by the transit agent assigned to the shipment — T1 is their own
// transit paperwork. Any other portal caller is rejected below.
@Post('bookings/:bookingId/t1-documents')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
})
uploadT1Documents(
async uploadT1Documents(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
if (
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) &&
!(await this.bookingsService.isTransitAgentForBooking(
user?.id,
bookingId,
))
) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
return this.glOperationsService.uploadT1Documents(
bookingId,
files ?? [],
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/t1-close')
@@ -1514,6 +1551,8 @@ export class ContractsController {
@MixedAudience(FREIGHT_PERMS.contracts.view)
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
// Reads are open to both audiences (a transit agent assigned to the
// shipment included); reporting an incident stays staff-only below.
return this.glOperationsService.listIncidents(bookingId);
}

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

@@ -51,6 +51,14 @@ export class CancelContractDto {
reason?: string;
}
/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */
export class CancelContractByStaffDto {
@ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' })
@IsString()
@MinLength(1)
reason!: string;
}
export class SuspendContractDto {
@ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' })
@IsString()

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

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