mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 16:43:39 +00:00
BIN
INV-20260812-00005-mor.pdf
Normal file
BIN
INV-20260812-00005-mor.pdf
Normal file
Binary file not shown.
BIN
INV-20260812-00005-thermal.pdf
Normal file
BIN
INV-20260812-00005-thermal.pdf
Normal file
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -96,6 +96,7 @@ import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { VerifaydaModule } from "./modules/verifayda/verifayda.module";
|
||||
import { EimsModule } from "./modules/eims/eims.module";
|
||||
import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module";
|
||||
import { WagonHistoryModule } from "./modules/wagon-history/wagon-history.module";
|
||||
import { WagonsModule } from "./modules/wagons/wagons.module";
|
||||
import { ContainersModule } from "./modules/container-management/containers.module";
|
||||
import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
||||
@@ -116,6 +117,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module";
|
||||
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
|
||||
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module";
|
||||
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
@@ -258,11 +260,13 @@ if (!process.env.APPLICATION_NAME) {
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
LastMileRequestsModule,
|
||||
EmptyReturnRequestsModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
EimsModule,
|
||||
FleetHistoryModule,
|
||||
WagonHistoryModule,
|
||||
AiModule,
|
||||
AuditModule,
|
||||
ChatModule,
|
||||
|
||||
148
apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts
Normal file
148
apps/edr-freight-api/src/common/freight-jwt.guard.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE =
|
||||
'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.';
|
||||
|
||||
export const EDR_HAULAGE_CONFLICT_MESSAGE =
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved';
|
||||
|
||||
/** The booking fields that decide whether the customer may still bring their own truck. */
|
||||
export interface MileCommitmentRow extends MileHaulageRow {
|
||||
/**
|
||||
* EDR has actually committed to the delivery leg: the booking's last-mile
|
||||
* request was approved, or a `freight.last_mile` leg row exists for it.
|
||||
* Selecting delivery on the contract is only a request — see
|
||||
* `edrHaulsThisBooking`.
|
||||
*/
|
||||
lastMileCommitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the
|
||||
* booking row aliased `b`. Both services that gate self-haul read the same
|
||||
* fragment so the rule cannot drift between them.
|
||||
*/
|
||||
export const LAST_MILE_COMMITTED_SQL = `(
|
||||
EXISTS (SELECT 1
|
||||
FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL)
|
||||
OR EXISTS (SELECT 1
|
||||
FROM freight.last_mile_requests lmr
|
||||
WHERE lmr.booking_id = b.id
|
||||
AND lmr.deleted_at IS NULL
|
||||
AND lmr.status = 'APPROVED')
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Whether EDR is hauling this booking's road leg, such that the customer may
|
||||
* NOT assign their own truck. Stricter than `usesEdrMileService` on the
|
||||
* delivery side: choosing last-mile delivery on the contract opens a request
|
||||
* that the Truck & Machinery chief still has to approve, and until that
|
||||
* approval the customer is free to self-haul instead. Collection (the export
|
||||
* leg) has no approval step, so the contract choice alone decides it.
|
||||
*
|
||||
* `usesEdrMileService` keeps answering the other question — whether the booking
|
||||
* belongs in the EDR mile queues at all — and the queue side still refuses a
|
||||
* booking that already carries a customer truck, so the two paths remain
|
||||
* mutually exclusive whichever acts first.
|
||||
*/
|
||||
export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean {
|
||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||
const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted;
|
||||
switch (booking.tradeDirection) {
|
||||
case 'IMPORT':
|
||||
return lastMileApproved;
|
||||
case 'EXPORT':
|
||||
return hasFirstMile;
|
||||
default:
|
||||
return hasFirstMile || lastMileApproved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract. A booking whose contract bought
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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/],
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 & 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 · 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>`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -232,3 +232,91 @@ describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', ()
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cancellation fee is paid BEFORE the credit is redeemed.
|
||||
*
|
||||
* An at-loading cut applies immediately and opens the credit while its fee
|
||||
* invoice stays open, so CREDIT_AVAILABLE on its own never means the fee was
|
||||
* settled. Without the gate the customer rebooks the same wagons and the
|
||||
* cancellation fee is simply never collected. EDR-fault cuts carry no fee and
|
||||
* must stay freely rebookable — partial or whole, container or bulk.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (cancellation fee gate)', () => {
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'ETB',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (row: Record<string, unknown>) => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & { rebook(id: string, dto: unknown): Promise<unknown> };
|
||||
svc.repo = { findById: async () => row };
|
||||
svc.bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async () => null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
/** Bulk credit — no bySize, so nothing depends on container snapshots. */
|
||||
const bulkRow = (over: Record<string, unknown>) => ({
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 5000,
|
||||
wagonsCancelled: 2,
|
||||
cancelledQuantities: { bulkTons: 100 },
|
||||
feeCurrency: 'ETB',
|
||||
...over,
|
||||
});
|
||||
|
||||
it('blocks a rebook while a customer-fault fee is unpaid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: null }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/pay the ETB 1500\.00 cancellation fee for 2 wagon\(s\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks a WHOLE-booking customer-fault cancel just the same', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 4000, feePaidAt: null, wagonsCancelled: 4 }),
|
||||
);
|
||||
await expect(svc.rebook('wc1', { scheduledDate: '2026-09-01' })).rejects.toThrow(
|
||||
/4 wagon\(s\) before rebooking/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the rebook through once the fee is paid', async () => {
|
||||
const svc = makeSvc(
|
||||
bulkRow({ fault: 'CUSTOMER', feeAmount: 1500, feePaidAt: new Date() }),
|
||||
);
|
||||
// Past the gate it fails later (no contract/create wiring in this harness) —
|
||||
// what matters is that it is no longer the fee that stops it.
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('never charges an EDR-fault cut', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: 'EDR', feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
|
||||
it('leaves legacy rows without a fee untouched', async () => {
|
||||
const svc = makeSvc(bulkRow({ fault: null, feeAmount: 0, feePaidAt: null }));
|
||||
await expect(
|
||||
svc.rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.not.toThrow(/cancellation fee/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
CancelledUnitSnapshot,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from './entities/booking-wagon-cancellation.entity';
|
||||
import { WagonEventType } from '@edr/types';
|
||||
import { WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
|
||||
export { WAGON_CANCEL_FEE_INVOICE_TYPE };
|
||||
|
||||
@@ -134,6 +136,7 @@ export class BookingWagonCancellationService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -1003,6 +1006,18 @@ export class BookingWagonCancellationService {
|
||||
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
|
||||
);
|
||||
}
|
||||
// Customer-fault fee settles BEFORE the credit is redeemed. An at-loading
|
||||
// cut applies immediately and opens the credit while its invoice stays
|
||||
// open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without
|
||||
// this the customer rebooks the wagons and never pays the cancellation
|
||||
// fee the notice already promised. EDR fault carries no fee and is
|
||||
// unaffected; onFeePaid stamps feePaidAt and the gate opens by itself.
|
||||
if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) {
|
||||
throw new BadRequestException(
|
||||
`Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` +
|
||||
`${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`,
|
||||
);
|
||||
}
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
||||
if (!source.contractId) {
|
||||
@@ -1835,6 +1850,7 @@ export class BookingWagonCancellationService {
|
||||
.getRepository(WagonAllocationContainerItem)
|
||||
.delete(cut.map((i) => i.id));
|
||||
if (cut.length === items.length) {
|
||||
await this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(alloc.id);
|
||||
} else {
|
||||
const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0);
|
||||
@@ -1881,9 +1897,66 @@ export class BookingWagonCancellationService {
|
||||
await manager
|
||||
.getRepository(WagonAllocationBulkLoad)
|
||||
.delete({ wagonBookingAllocationId: In(ids) });
|
||||
await this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking');
|
||||
await manager.getRepository(WagonBookingAllocation).delete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* BOOKING_CANCELLED history row for every physical wagon behind the released
|
||||
* allocations — resolved through the slot BEFORE the allocation rows go, one
|
||||
* query for the whole batch. Slots with no wagon pinned yet leave no row.
|
||||
*/
|
||||
private async recordAllocationRelease(
|
||||
manager: EntityManager,
|
||||
allocationIds: string[],
|
||||
bookingId: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
if (!allocationIds.length) return;
|
||||
const rows: Array<{
|
||||
allocationId: string;
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
yardId: string | null;
|
||||
trainId: string | null;
|
||||
scheduleId: string | null;
|
||||
weightTons: string | null;
|
||||
loadType: string | null;
|
||||
}> = await manager.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
w.id AS "wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
w.current_yard_id AS "yardId",
|
||||
w.train_id AS "trainId",
|
||||
w.current_train_schedule_id AS "scheduleId",
|
||||
a.allocated_weight_tons AS "weightTons",
|
||||
a.load_type AS "loadType"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE a.id = ANY($1::uuid[])`,
|
||||
[allocationIds],
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
rows.map((r) => ({
|
||||
wagonId: r.wagonId,
|
||||
wagonNumber: r.wagonNumber,
|
||||
type: WagonEventType.BookingCancelled,
|
||||
fromYardId: r.yardId,
|
||||
trainId: r.trainId,
|
||||
trainScheduleId: r.scheduleId,
|
||||
bookingId,
|
||||
reason,
|
||||
metadata: {
|
||||
allocationId: r.allocationId,
|
||||
loadType: r.loadType,
|
||||
weightTons: r.weightTons == null ? null : Number(r.weightTons),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-reduction quantities snapshot (only when the booking was never split before). */
|
||||
private async currentQuantities(
|
||||
manager: EntityManager,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
|
||||
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
|
||||
import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto";
|
||||
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
|
||||
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
|
||||
import { CustomerTruckService } from "./customer-truck.service";
|
||||
@@ -664,9 +666,11 @@ export class BookingsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
// GL (createBooking) rebooks credits and must see the ledger for that.
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -783,12 +787,78 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
/**
|
||||
* Scope a clearance READ that a transit agent may be making.
|
||||
*
|
||||
* Transit agents are portal accounts holding no permission and belonging to
|
||||
* no company, so the audience guards admit them but the usual company-based
|
||||
* ownership check would 404 every booking. This narrows them to the shipments
|
||||
* assigned to them and leaves every other caller — staff and owning customers
|
||||
* — on the path they already had. Purely widening: nothing that passed before
|
||||
* starts failing here.
|
||||
*/
|
||||
private async assertTransitAgentScope(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
const userId = user?.id;
|
||||
if (!userId) return;
|
||||
if (!(await this.bookingsService.isTransitAgent(userId))) return;
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(userId, bookingId))
|
||||
) {
|
||||
// Hidden behind a NotFound so booking ids stay unprobeable, matching the
|
||||
// customer-ownership failure mode.
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a formerly staff-only clearance route that is now MixedAudience.
|
||||
*
|
||||
* Staff still pass on their permission. A portal caller must be a transit
|
||||
* agent assigned to THIS booking — an ordinary customer is rejected, because
|
||||
* relaxing the guard must not hand the whole customer base a route that was
|
||||
* previously staff-only.
|
||||
*
|
||||
* Used for the Djibouti-desk WRITES too (DO/RO upload, RO amendment): the
|
||||
* assigned agent files them in the desk's place, and the assignment is the
|
||||
* only thing standing between a portal token and the customs record.
|
||||
*/
|
||||
private async assertPortalClearanceAccess(
|
||||
bookingId: string,
|
||||
user: TCurrentUser,
|
||||
): Promise<void> {
|
||||
if (
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWagonCancellationActor(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
// Rebooking a credit creates a booking under the contract — GL's booking
|
||||
// creation key covers it even where the dedicated rebook key was never granted.
|
||||
if (
|
||||
staffPermission === FREIGHT_PERMS.bookings.wagonCancellationRebook &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.createBooking)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
@@ -848,7 +918,7 @@ export class BookingsController {
|
||||
})
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@Body() payload: BulkCustomerTrucksDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
@@ -1128,6 +1198,9 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Get(":id/clearance")
|
||||
// A transit agent is a portal account, so MixedAudience admits them without a
|
||||
// permission; `assertTransitAgentScope` below narrows them to the shipments
|
||||
// actually assigned to them.
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.clearanceView,
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
@@ -1136,7 +1209,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Document-clearance grid (required docs + upload + GL review status)",
|
||||
})
|
||||
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearance(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertTransitAgentScope(id, user);
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@@ -1278,8 +1355,11 @@ export class BookingsController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Was staff-only. Opened to the transit agent assigned to the shipment, who
|
||||
// needs the clearance trail for the bookings they handle; every other portal
|
||||
// account is still rejected by the scope check below.
|
||||
@Get(":id/clearance/history")
|
||||
@BookingStaff([
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
])
|
||||
@@ -1287,7 +1367,11 @@ export class BookingsController {
|
||||
summary:
|
||||
"Clearance action history for the booking — reviews, workflow steps, charges (newest first)",
|
||||
})
|
||||
getClearanceHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
async getClearanceHistory(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.clearanceEventService.list(id);
|
||||
}
|
||||
|
||||
@@ -1310,6 +1394,12 @@ export class BookingsController {
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
if (isStaff) return this.clearanceChargeService.list(id);
|
||||
// The transit agent handling this shipment sees the same customer-facing
|
||||
// slice the customer does — charges actually sent, never the internal
|
||||
// draft/billing view `list()` returns.
|
||||
if (await this.bookingsService.isTransitAgentForBooking(user?.id, id)) {
|
||||
return this.clearanceChargeService.listForCustomer(id);
|
||||
}
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
@@ -1777,8 +1867,10 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// Djibouti-desk write, also filed by the transit agent assigned to this
|
||||
// shipment — `assertPortalClearanceAccess` rejects every other portal caller.
|
||||
@Post(":id/clearance/delivery-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingDeliveryOrder(
|
||||
@@ -1788,6 +1880,7 @@ export class BookingsController {
|
||||
@Body("doCollectedDate") doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1798,7 +1891,7 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(":id/clearance/release-order")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingReleaseOrder(
|
||||
@@ -1807,6 +1900,7 @@ export class BookingsController {
|
||||
@Body("vesselDepartureDate") vesselDepartureDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const result = await this.bookingClearanceService.uploadReleaseOrder(
|
||||
id,
|
||||
files ?? [],
|
||||
@@ -1820,13 +1914,69 @@ export class BookingsController {
|
||||
};
|
||||
}
|
||||
|
||||
// Transit-agent arrival paperwork (export): gate pass and Djibouti T1 sets.
|
||||
// Same audience rule as the DO/RO uploads above — the desk, or the agent
|
||||
// assigned to this shipment.
|
||||
@Post(":id/clearance/gate-pass-documents")
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingGatePassDocuments(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.bookingClearanceService.uploadTransitArrivalDocuments(
|
||||
id,
|
||||
"gate_pass",
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/djibouti-t1-documents")
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
async uploadBookingDjiboutiT1Documents(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
return this.bookingClearanceService.uploadTransitArrivalDocuments(
|
||||
id,
|
||||
"djibouti_t1",
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(":id/clearance/transit-documents/:fileId")
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@HttpCode(204)
|
||||
async removeBookingTransitDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
await this.bookingClearanceService.removeTransitArrivalDocument(
|
||||
id,
|
||||
fileId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/clearance/ro-amendment")
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
async requestBookingRoAmendment(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RoAmendmentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertPortalClearanceAccess(id, user);
|
||||
const booking = await this.bookingClearanceService.requestRoAmendment(
|
||||
id,
|
||||
dto.note,
|
||||
|
||||
@@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
LAST_MILE_COMMITTED_SQL,
|
||||
edrHaulsThisBooking,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -112,6 +117,9 @@ interface CarriageAcceptanceWagonRow {
|
||||
departureAt: Date | null;
|
||||
marshalledAt: string | null;
|
||||
arrivalAt: string | null;
|
||||
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
|
||||
departureStation: string | null;
|
||||
arrivalStation: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
|
||||
@@ -171,18 +179,23 @@ export class BookingsService {
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
|
||||
// closes self-haul only once it has been approved.
|
||||
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
|
||||
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (
|
||||
edrHaulsThisBooking({
|
||||
tradeDirection: booking.tradeDirection ?? null,
|
||||
firstMile: booking.firstMilePickupAddress ?? null,
|
||||
lastMile: booking.lastMileDeliveryAddress ?? null,
|
||||
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
|
||||
})
|
||||
) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
throw new ConflictException('Customer truck assignment is already submitted and locked');
|
||||
@@ -291,6 +304,8 @@ export class BookingsService {
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
COALESCE(by_.label, so.label) AS "departureStation",
|
||||
COALESCE(ay.label, sd.label) AS "arrivalStation",
|
||||
a.status AS "status",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
@@ -303,13 +318,31 @@ export class BookingsService {
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
|
||||
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
AND (
|
||||
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
|
||||
SELECT 1
|
||||
FROM freight.booking_container_units received_unit
|
||||
JOIN freight.booking_container received_line
|
||||
ON received_line.id = received_unit.booking_container_id
|
||||
AND received_line.deleted_at IS NULL
|
||||
WHERE received_line.booking_id = a.booking_id
|
||||
AND received_unit.container_number = ci.container_number
|
||||
AND received_unit.received_to_port = true
|
||||
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
|
||||
AND received_unit.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label,
|
||||
by_.label, ay.label
|
||||
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
[bookingId, booking.tradeDirection, booking.freightType],
|
||||
);
|
||||
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
|
||||
// takes custody of the cargo when it receives it, and the customer is handed
|
||||
@@ -344,17 +377,17 @@ export class BookingsService {
|
||||
)
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
ORDER BY inv.created_at`,
|
||||
`SELECT unit.vgm_tons AS "allocatedWeightTons",
|
||||
unit.container_number AS "containerNumbers",
|
||||
unit.seal_number AS "sealNumbers"
|
||||
FROM freight.booking_container_units unit
|
||||
JOIN freight.booking_container line
|
||||
ON line.id = unit.booking_container_id AND line.deleted_at IS NULL
|
||||
WHERE line.booking_id = $1
|
||||
AND unit.deleted_at IS NULL
|
||||
AND unit.received_to_port = true
|
||||
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
|
||||
ORDER BY unit.received_at, unit.container_number`,
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
@@ -388,6 +421,8 @@ export class BookingsService {
|
||||
departureAt: null,
|
||||
marshalledAt: null,
|
||||
arrivalAt: null,
|
||||
departureStation: null,
|
||||
arrivalStation: null,
|
||||
containerNumbers: row.containerNumbers,
|
||||
sealNumbers: row.sealNumbers ?? null,
|
||||
// A received line has no allocation; it is cargo EDR already holds.
|
||||
@@ -539,9 +574,9 @@ export class BookingsService {
|
||||
<td class="num">${num(w.tareWeightTons, 2)}</td>
|
||||
<td class="num">${num(w.equatedLength)}</td>
|
||||
<td class="num">${num(w.loadCapacityTons)}</td>
|
||||
<td>${esc(arrivalStation)}</td>
|
||||
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
|
||||
<td>${esc(cargoName)}</td>
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.departureStation ?? departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</td>
|
||||
<td>${esc(w.sealNumbers)}</td>
|
||||
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
|
||||
@@ -558,16 +593,12 @@ export class BookingsService {
|
||||
const totalsRow = `<tr class="totals">
|
||||
<td>TOT</td>
|
||||
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
|
||||
<td>${
|
||||
pendingWagons
|
||||
? 'pending marshalling'
|
||||
: `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}`
|
||||
}</td>
|
||||
<td></td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td></td>
|
||||
<td>Gross ${num(totals.tare + totals.load)} T</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
@@ -575,6 +606,23 @@ export class BookingsService {
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>`;
|
||||
|
||||
// The signed footer of the paper sheet. Rendered as .tile so the
|
||||
// Chromium-less fallback (buildTabularFallbackPdf parses .tile, not
|
||||
// arbitrary divs) still prints every figure.
|
||||
const footer = `
|
||||
<div class="summary footer-summary">
|
||||
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
|
||||
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
|
||||
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
|
||||
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
|
||||
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
|
||||
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
|
||||
<div class="tile"><span>Empty Wagon</span><strong>${
|
||||
pendingWagons ? '-' : loadedWagons.length - fullWagons
|
||||
}</strong></div>
|
||||
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
|
||||
</div>`;
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -591,6 +639,8 @@ export class BookingsService {
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
|
||||
.footer-summary .tile { background: #f8fafc; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
@@ -652,6 +702,7 @@ export class BookingsService {
|
||||
${totalsRow}
|
||||
</tbody>
|
||||
</table>
|
||||
${footer}
|
||||
|
||||
<div class="notice">
|
||||
${
|
||||
@@ -2014,6 +2065,65 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `userId` is a transit agent currently assigned to this booking.
|
||||
*
|
||||
* Deliberately NOT folded into {@link assertCustomerCanAccessBooking}: that
|
||||
* assertion guards ~29 call sites, including wagon cancellations, rebooking
|
||||
* and customer-truck writes. A transit agent must reach the clearance READS
|
||||
* for the shipments they handle and nothing else, so the two ownership rules
|
||||
* stay separate and each caller opts in explicitly.
|
||||
*
|
||||
* Queried directly rather than through TransitAssignmentsService: that module
|
||||
* imports BookingsModule, so injecting it here would close an import cycle.
|
||||
*/
|
||||
/** Is this portal account a transit agent at all? */
|
||||
async isTransitAgent(userId: string | undefined): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_agents a
|
||||
WHERE a.user_id = $1 AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async isTransitAgentForBooking(
|
||||
userId: string | undefined,
|
||||
bookingId: string,
|
||||
): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const rows: { one: number }[] = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.transit_assignments ta
|
||||
JOIN freight.transit_agents a ON a.id = ta.transit_agent_id
|
||||
WHERE a.user_id = $1
|
||||
AND ta.booking_id = $2
|
||||
AND ta.deleted_at IS NULL
|
||||
AND a.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[userId, bookingId],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a clearance READ on one booking for either audience a portal
|
||||
* account can be: the owning customer, or a transit agent assigned to it.
|
||||
*
|
||||
* Read-only by contract — every caller is a GET. Writes keep using
|
||||
* {@link assertCustomerCanAccessBooking}, which a transit agent never passes.
|
||||
*/
|
||||
async assertCanReadBookingClearance(
|
||||
userId: string | undefined,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (await this.isTransitAgentForBooking(userId, booking.id)) return;
|
||||
await this.assertCustomerCanAccessBooking(userId, booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the customer-facing shipment tracking payload for a booking from the
|
||||
* train schedule it is assigned to and the live checkpoint log. The caller is
|
||||
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>` : ''
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ function makeService(overrides?: {
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
{ ensureAssignment: jest.fn() } as never, // transit assignments
|
||||
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
|
||||
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
{} as never, // transitAgentsService
|
||||
{} as never, // transitAssignmentsService
|
||||
{} as never, // dataSource
|
||||
);
|
||||
build([
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
@@ -1359,19 +1360,35 @@ export class ContractsController {
|
||||
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
|
||||
}
|
||||
|
||||
// Also filed by the transit agent assigned to the shipment — T1 is their own
|
||||
// transit paperwork. Any other portal caller is rejected below.
|
||||
@Post('bookings/:bookingId/t1-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
|
||||
})
|
||||
uploadT1Documents(
|
||||
async uploadT1Documents(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
|
||||
if (
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) &&
|
||||
!(await this.bookingsService.isTransitAgentForBooking(
|
||||
user?.id,
|
||||
bookingId,
|
||||
))
|
||||
) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
return this.glOperationsService.uploadT1Documents(
|
||||
bookingId,
|
||||
files ?? [],
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/t1-close')
|
||||
@@ -1534,6 +1551,8 @@ export class ContractsController {
|
||||
@MixedAudience(FREIGHT_PERMS.contracts.view)
|
||||
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
|
||||
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
// Reads are open to both audiences (a transit agent assigned to the
|
||||
// shipment included); reporting an incident stays staff-only below.
|
||||
return this.glOperationsService.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -50,6 +50,7 @@ describe('GlOperationsService — final invoice approval', () => {
|
||||
{} as never, // milestoneService
|
||||
billingService as never,
|
||||
notifier as never,
|
||||
{ record: jest.fn() } as never, // clearanceEvents
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" })
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 booking’s flagged containers onto one row, all pending', () => {
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }),
|
||||
unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }),
|
||||
]);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].bookingReference).toBe('BK-2026-000263');
|
||||
expect(rows[0].companyName).toBe('Afri Software Solutions');
|
||||
expect(rows[0].containers.map((c) => c.containerNumber)).toEqual([
|
||||
'MSFH8596324',
|
||||
'SDJU8596324',
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 0, pendingCount: 2 });
|
||||
});
|
||||
|
||||
it('keeps an already-recorded container visible but out of the pending count', () => {
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({
|
||||
unitId: 'u1',
|
||||
containerNumber: 'MSFH8596324',
|
||||
returnId: 'r1',
|
||||
returnStatus: 'ASSIGNED_STORAGE',
|
||||
}),
|
||||
unit({ unitId: 'u2', containerNumber: 'SDJU8596324' }),
|
||||
]);
|
||||
|
||||
expect(rows[0]).toMatchObject({ expectedCount: 2, recordedCount: 1, pendingCount: 1 });
|
||||
expect(rows[0].containers[0].returnStatus).toBe('ASSIGNED_STORAGE');
|
||||
});
|
||||
|
||||
it('drops a booking once every container is recorded', () => {
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({
|
||||
unitId: 'u1',
|
||||
containerNumber: 'MSFH8596324',
|
||||
returnId: 'r1',
|
||||
returnStatus: 'RETURNED',
|
||||
}),
|
||||
unit({
|
||||
unitId: 'u2',
|
||||
containerNumber: 'SDJU8596324',
|
||||
returnId: 'r2',
|
||||
returnStatus: 'COMPLETED',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps each booking on its own row, in query order', () => {
|
||||
const other = {
|
||||
...booking,
|
||||
bookingId: 'b2',
|
||||
bookingReference: 'BK-2026-000286',
|
||||
companyName: 'DE BE KE',
|
||||
};
|
||||
const rows = assembleEmptyReturnBookings([
|
||||
unit({ unitId: 'u1', containerNumber: 'MSFH8596324' }),
|
||||
{ ...unit({ unitId: 'u2', containerNumber: 'ASDS1234567' }), ...other },
|
||||
unit({ unitId: 'u3', containerNumber: 'SDJU8596324' }),
|
||||
]);
|
||||
|
||||
expect(rows.map((r) => r.bookingReference)).toEqual(['BK-2026-000263', 'BK-2026-000286']);
|
||||
expect(rows[0].containers).toHaveLength(2);
|
||||
expect(rows[1].containers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns nothing when no booking owes an empty', () => {
|
||||
expect(assembleEmptyReturnBookings([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -119,6 +119,15 @@ export class ImportOperationsController {
|
||||
return this.service.listEmptyReturns();
|
||||
}
|
||||
|
||||
@Get('empty-return-bookings')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Bookings shipping with empty-container return that still owe empties, with their containers',
|
||||
})
|
||||
listEmptyReturnBookings() {
|
||||
return this.service.listEmptyReturnBookings();
|
||||
}
|
||||
|
||||
@Post('empty-container-returns')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { EmptyReturnRequestsModule } from '../empty-return-requests/empty-return-requests.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||
@@ -26,6 +27,9 @@ import { ImportOperationsService } from './import-operations.service';
|
||||
BookingsModule,
|
||||
NotificationInboxModule,
|
||||
NotificationsModule,
|
||||
// Recording a return is what closes out the customer's scheduled empty
|
||||
// return request, once every container on it is back.
|
||||
EmptyReturnRequestsModule,
|
||||
],
|
||||
controllers: [ImportOperationsController],
|
||||
providers: [ImportOperationsService],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util'
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { EmptyReturnRequestsService } from '../empty-return-requests/empty-return-requests.service';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
BulkCreateEmptyContainerReturnsDto,
|
||||
@@ -25,6 +26,13 @@ import {
|
||||
type DjiboutiIncidentType,
|
||||
} from './entities/djibouti-incident.entity';
|
||||
import { assertWagonLoad } from './empty-container-wagon.util';
|
||||
import {
|
||||
assembleEmptyReturnBookings,
|
||||
EMPTY_RETURN_CLOSED_BOOKING_STATUSES,
|
||||
WITH_RETURN_EQUIPMENT_VALUES,
|
||||
type EmptyReturnBookingRow,
|
||||
type EmptyReturnBookingUnitRow,
|
||||
} from './empty-return-bookings.util';
|
||||
import {
|
||||
EmptyContainerReturn,
|
||||
type EmptyContainerReturnListItem,
|
||||
@@ -57,6 +65,7 @@ export class ImportOperationsService {
|
||||
private readonly logoSettings: LogoSettingsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly emptyReturnRequests: EmptyReturnRequestsService,
|
||||
) {}
|
||||
|
||||
listIncidents(bookingId?: string) {
|
||||
@@ -207,6 +216,51 @@ export class ImportOperationsService {
|
||||
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings that ship WITH empty-container return and still owe empties, each
|
||||
* with the containers that are to be returned — the ones the booking flagged
|
||||
* `is_return`, carrying the empty return already recorded against each, if
|
||||
* any.
|
||||
*/
|
||||
async listEmptyReturnBookings(): Promise<EmptyReturnBookingRow[]> {
|
||||
const units: EmptyReturnBookingUnitRow[] = await this.emptyReturns.manager.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.status AS "bookingStatus",
|
||||
b.equipment_return AS "equipmentReturn",
|
||||
b.company_id AS "customerId",
|
||||
c.name AS "companyName",
|
||||
u.id AS "unitId",
|
||||
u.container_number AS "containerNumber",
|
||||
COALESCE(bc.container_size, ct.code) AS "containerSize",
|
||||
ct.label AS "containerType",
|
||||
r.id AS "returnId",
|
||||
r.status AS "returnStatus"
|
||||
FROM freight.booking_container_units u
|
||||
JOIN freight.booking_container bc ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = bc.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT er.id, er.status
|
||||
FROM freight.empty_container_returns er
|
||||
WHERE er.deleted_at IS NULL
|
||||
AND er.booking_id = b.id
|
||||
AND upper(er.container_number) = upper(u.container_number)
|
||||
ORDER BY er.created_at DESC
|
||||
LIMIT 1
|
||||
) r ON TRUE
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND u.is_return = true
|
||||
AND b.equipment_return = ANY($1)
|
||||
AND b.status <> ALL($2)
|
||||
ORDER BY b.created_at DESC, u.sort_order ASC`,
|
||||
[WITH_RETURN_EQUIPMENT_VALUES, EMPTY_RETURN_CLOSED_BOOKING_STATUSES],
|
||||
);
|
||||
|
||||
return assembleEmptyReturnBookings(units);
|
||||
}
|
||||
|
||||
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
||||
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
|
||||
const saved = await this.emptyReturns.save(
|
||||
@@ -236,6 +290,8 @@ export class ImportOperationsService {
|
||||
// Standalone returns (no booking) have no company to notify.
|
||||
if (saved.bookingId) {
|
||||
await this.notifyEquipmentInterchangeReady(saved);
|
||||
// Closes the customer's scheduled request once its last container is in.
|
||||
await this.emptyReturnRequests.settleScheduledForBooking(saved.bookingId);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ export class SchedulingRescheduleService {
|
||||
// M12: only announce a new departure when the date actually moved —
|
||||
// `newDeparture` is null when the date was unchanged, so retained customers
|
||||
// are not falsely told the train was rescheduled.
|
||||
await this.notifyRescheduleOutcome(dto, newDeparture);
|
||||
await this.notifyRescheduleOutcome(scheduleId, dto, newDeparture);
|
||||
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
|
||||
|
||||
return { plan, schedule: assignResult };
|
||||
@@ -283,6 +283,7 @@ export class SchedulingRescheduleService {
|
||||
* company so the notifier has a phone/email to reach.
|
||||
*/
|
||||
private async notifyRescheduleOutcome(
|
||||
scheduleId: string,
|
||||
dto: ExecuteRescheduleDto,
|
||||
newDeparture: Date | null,
|
||||
): Promise<void> {
|
||||
@@ -294,9 +295,9 @@ export class SchedulingRescheduleService {
|
||||
const booking = await this.loadBookingForNotify(bookingId);
|
||||
if (!booking) continue;
|
||||
if (isMaintenance) {
|
||||
this.notifier.maintenanceMoved(booking, newDeparture);
|
||||
this.notifier.maintenanceMoved(booking, newDeparture, scheduleId, dto.reason);
|
||||
} else {
|
||||
this.notifier.rescheduled(booking, newDeparture);
|
||||
this.notifier.rescheduled(booking, newDeparture, scheduleId, dto.reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,7 +308,8 @@ export class SchedulingRescheduleService {
|
||||
for (const bookingId of dto.displacedBookingIds) {
|
||||
const booking = await this.loadBookingForNotify(bookingId);
|
||||
if (!booking) continue;
|
||||
this.notifier.removedFromTrain(booking);
|
||||
// Displaced bookings no longer point at the schedule — pass it explicitly.
|
||||
this.notifier.removedFromTrain(booking, scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { trainRunLabel } from './train-run-label.util';
|
||||
|
||||
describe('trainRunLabel', () => {
|
||||
it('names the departure by the schedule train number and voyage number', () => {
|
||||
expect(trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' })).toBe(
|
||||
'train 8001 (voyage V-117)',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the voyage bracket when the schedule has no voyage number', () => {
|
||||
expect(trainRunLabel({ trainNumber: '8001', voyageNumber: null })).toBe('train 8001');
|
||||
expect(trainRunLabel({ trainNumber: '8001', voyageNumber: ' ' })).toBe('train 8001');
|
||||
});
|
||||
|
||||
it('still quotes the voyage when the pool train number is not assigned yet', () => {
|
||||
expect(trainRunLabel({ trainNumber: null, voyageNumber: 'V-117' })).toBe(
|
||||
'train (voyage V-117)',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when neither number is known so callers can fall back', () => {
|
||||
expect(trainRunLabel({ trainNumber: null, voyageNumber: null })).toBeNull();
|
||||
expect(trainRunLabel(null)).toBeNull();
|
||||
expect(trainRunLabel(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('capitalizes for sentence starts on request', () => {
|
||||
expect(
|
||||
trainRunLabel({ trainNumber: '8001', voyageNumber: 'V-117' }, { capitalize: true }),
|
||||
).toBe('Train 8001 (voyage V-117)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TrainSchedule } from './entities/train-schedule.entity';
|
||||
|
||||
export type TrainRunSource = Pick<TrainSchedule, 'trainNumber' | 'voyageNumber'>;
|
||||
|
||||
/**
|
||||
* How a departure is named in every customer-facing SMS / email:
|
||||
*
|
||||
* "train 8001 (voyage V-2026-117)"
|
||||
*
|
||||
* Both identifiers are the SCHEDULE's own columns — `train_schedules.train_number`
|
||||
* and `train_schedules.voyage_number`. The built train (`freight.trains`) carries
|
||||
* a `train_name` that the build form labels "voyage number"; that is a different
|
||||
* identifier and must never be quoted to customers. Always pass the schedule.
|
||||
*
|
||||
* Returns null when the schedule has neither number (older rows, or an unbuilt
|
||||
* departure whose pool number is assigned at dispatch) so callers can fall back
|
||||
* to a generic phrase instead of printing "train (voyage)".
|
||||
*/
|
||||
export function trainRunLabel(
|
||||
schedule: TrainRunSource | null | undefined,
|
||||
opts: { capitalize?: boolean } = {},
|
||||
): string | null {
|
||||
if (!schedule) return null;
|
||||
const train = schedule.trainNumber?.trim() || null;
|
||||
const voyage = schedule.voyageNumber?.trim() || null;
|
||||
if (!train && !voyage) return null;
|
||||
const head = train ? `train ${train}` : 'train';
|
||||
const label = voyage ? `${head} (voyage ${voyage})` : head;
|
||||
return opts.capitalize ? label.charAt(0).toUpperCase() + label.slice(1) : label;
|
||||
}
|
||||
@@ -607,7 +607,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const isBatchPaid =
|
||||
booking.status === "SELECTED_FOR_BATCH" ||
|
||||
booking.status === "AWAITING_PAYMENT" ||
|
||||
booking.status === "PAID" ||
|
||||
booking.paymentStatus === "PAID";
|
||||
if (!isBatchPaid) return;
|
||||
|
||||
@@ -786,7 +785,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`SELECT id FROM freight.bookings
|
||||
WHERE deleted_at IS NULL
|
||||
AND train_schedule_id IS NULL
|
||||
AND (payment_status = 'PAID' OR status = 'PAID')
|
||||
AND payment_status = 'PAID'
|
||||
AND scheduled_date IS NOT NULL
|
||||
AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`,
|
||||
[day],
|
||||
@@ -3476,7 +3475,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedule?.scheduledDepartureDate &&
|
||||
eatDay(schedule.scheduledDepartureDate) !== previousDay
|
||||
) {
|
||||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
|
||||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3819,7 +3818,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
fresh.trainScheduleId === scheduleId &&
|
||||
(fresh.status === "SELECTED_FOR_BATCH" ||
|
||||
fresh.status === "AWAITING_PAYMENT" ||
|
||||
fresh.status === "PAID" ||
|
||||
fresh.paymentStatus === "PAID")
|
||||
) {
|
||||
this.logger.debug(
|
||||
@@ -4535,7 +4533,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
manager,
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
this.notifier.displaced(victim, scheduleId);
|
||||
budget.add(this.needFor(victim, wagonDims), victimLeg);
|
||||
// Displacing frees wagons the same way an expiry does — don't leave the
|
||||
// schedule stuck at FULL.
|
||||
@@ -5489,7 +5487,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
).filter(
|
||||
(b) =>
|
||||
b.paymentStatus === "PAID" ||
|
||||
b.status === "PAID" ||
|
||||
!payWindowLapsed(b.paymentDeadline, deadlineCutoff),
|
||||
);
|
||||
// Export FCFS: a customer's pending operation request HOLDS its wagons from
|
||||
@@ -5601,7 +5598,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return reserved.some(
|
||||
(b) =>
|
||||
b.paymentStatus !== "PAID" &&
|
||||
b.status !== "PAID" &&
|
||||
b.paymentDeadline != null &&
|
||||
!payWindowLapsed(b.paymentDeadline, now),
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
|
||||
{ emit: jest.fn() } as never, // events
|
||||
{} as never, // notifications
|
||||
{} as never, // inbox
|
||||
{ record: jest.fn() } as never, // wagonHistory
|
||||
);
|
||||
|
||||
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
||||
|
||||
@@ -31,12 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import {
|
||||
notifyCarriageAcceptanceReady,
|
||||
notifyLoadManifest,
|
||||
} from '../notifications/notify-company.util';
|
||||
import { notifyCarriageAcceptanceReady,notifyLoadManifest } from '../notifications/notify-company.util';
|
||||
import { WagonEventInput, WagonHistoryService } from '../wagon-history/wagon-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
*
|
||||
@@ -65,12 +64,18 @@ export class BookingJourneyService {
|
||||
private readonly events: EventEmitter2,
|
||||
private readonly notifications: NotificationsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly wagonHistory: WagonHistoryService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
/** Statuses from which a booking may be loaded (gov bookings don't prepay). */
|
||||
/**
|
||||
* Whether a booking may be loaded. Paid is decided by the booking's
|
||||
* PAYMENT status only — never by `status === 'PAID'`, which lags or is
|
||||
* skipped on several flows (batch pay, manual mark-paid, gov expedite).
|
||||
* Government bookings don't prepay: APPROVED is enough for them.
|
||||
*/
|
||||
private canLoad(booking: Booking): boolean {
|
||||
if (booking.status === 'PAID') return true;
|
||||
if (booking.paymentStatus === 'PAID') return true;
|
||||
return booking.isGovernment && booking.status === 'APPROVED';
|
||||
}
|
||||
|
||||
@@ -121,6 +126,7 @@ export class BookingJourneyService {
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
});
|
||||
await this.wagonHistory.record(manager, this.cargoEvent(target, schedule, booking, 'LOADED', now, userId ?? null));
|
||||
if (!booking.loadingStartedAt) {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
@@ -192,7 +198,8 @@ export class BookingJourneyService {
|
||||
}
|
||||
if (!this.canLoad(booking)) {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid before loading (currently ${booking.status})`,
|
||||
`Booking must be paid before loading (payment status ${booking.paymentStatus ?? 'PENDING'}, ` +
|
||||
`booking status ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
@@ -239,7 +246,12 @@ export class BookingJourneyService {
|
||||
if (booking.tradeDirection === 'DOMESTIC') {
|
||||
await this.autoPlaceOnFreedWagons(manager, schedule, booking);
|
||||
}
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED', {
|
||||
userId: userId ?? null,
|
||||
at: now,
|
||||
schedule,
|
||||
booking,
|
||||
});
|
||||
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
|
||||
// readiness warnings and workspace badges read loading_status, not loadedAt.
|
||||
await manager
|
||||
@@ -346,6 +358,7 @@ export class BookingJourneyService {
|
||||
unloadedAt: now,
|
||||
unloadedByUserId: userId ?? null,
|
||||
});
|
||||
await this.wagonHistory.record(null, this.cargoEvent(target, schedule, booking, 'DEPARTED', now, userId ?? null));
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'DEPARTED',
|
||||
@@ -403,7 +416,12 @@ export class BookingJourneyService {
|
||||
arrivedAt: now,
|
||||
arrivedByUserId: userId ?? null,
|
||||
} as never);
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED', {
|
||||
userId: userId ?? null,
|
||||
at: now,
|
||||
schedule,
|
||||
booking,
|
||||
});
|
||||
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
||||
// The facility took the cargo off the train — raise its GRN. Where the
|
||||
// facility also stores cargo (Indode), the event links the storage record
|
||||
@@ -482,6 +500,7 @@ export class BookingJourneyService {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
status: b.status,
|
||||
paymentStatus: b.paymentStatus ?? null,
|
||||
tradeDirection: b.tradeDirection,
|
||||
isGovernment: b.isGovernment,
|
||||
customer: b.company?.name ?? 'Unknown customer',
|
||||
@@ -919,12 +938,61 @@ export class BookingJourneyService {
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
status: 'LOADED' | 'DEPARTED',
|
||||
ctx?: { userId: string | null; at: Date; schedule: TrainSchedule; booking: Booking },
|
||||
): Promise<void> {
|
||||
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
|
||||
if (!allocations.length) return;
|
||||
await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.update({ id: In(allocations.map((a) => a.id)) }, { status });
|
||||
if (!ctx) return;
|
||||
// Per-wagon cargo history. Allocations already at (or past) the target
|
||||
// status were logged by the per-wagon load/unload endpoint — skip them so
|
||||
// the whole-booking completion never double-writes a wagon's row.
|
||||
const pending = allocations.filter((a) =>
|
||||
status === 'LOADED'
|
||||
? a.status !== 'LOADED' && a.status !== 'DEPARTED'
|
||||
: a.status !== 'DEPARTED',
|
||||
);
|
||||
await this.wagonHistory.record(
|
||||
manager,
|
||||
pending
|
||||
.map((a) => this.cargoEvent(a, ctx.schedule, ctx.booking, status, ctx.at, ctx.userId))
|
||||
.filter((e): e is WagonEventInput => e !== null),
|
||||
);
|
||||
}
|
||||
|
||||
/** CARGO_LOADED / CARGO_UNLOADED row for one allocation's physical wagon; null when the slot has no wagon pinned. */
|
||||
private cargoEvent(
|
||||
alloc: WagonBookingAllocation & { trainSetWagon?: TrainSetWagon },
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
status: 'LOADED' | 'DEPARTED',
|
||||
at: Date,
|
||||
userId: string | null,
|
||||
): WagonEventInput | null {
|
||||
const slot = alloc.trainSetWagon;
|
||||
if (!slot?.physicalWagonId) return null;
|
||||
const loaded = status === 'LOADED';
|
||||
return {
|
||||
wagonId: slot.physicalWagonId,
|
||||
wagonNumber: slot.physicalWagon?.wagonNumber ?? null,
|
||||
type: loaded ? Freight.WagonEventType.CargoLoaded : Freight.WagonEventType.CargoUnloaded,
|
||||
occurredAt: at,
|
||||
actorUserId: userId,
|
||||
toYardId: loaded
|
||||
? (slot.boardYardId ?? schedule.originStationId ?? null)
|
||||
: (booking.destinationYardId ?? slot.alightYardId ?? schedule.destinationStationId ?? null),
|
||||
trainScheduleId: schedule.id,
|
||||
trainId: schedule.trainSet?.trainId ?? null,
|
||||
bookingId: booking.id,
|
||||
toValue: booking.reference ?? null,
|
||||
metadata: {
|
||||
allocationId: alloc.id,
|
||||
loadType: alloc.loadType ?? null,
|
||||
weightTons: Number(alloc.allocatedWeightTons ?? 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async allocationsForBooking(
|
||||
@@ -1012,6 +1080,20 @@ export class BookingJourneyService {
|
||||
? Freight.WagonStatus.Assigned
|
||||
: Freight.WagonStatus.Available,
|
||||
});
|
||||
await this.wagonHistory.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: Freight.WagonEventType.ReleasedAtUnload,
|
||||
occurredAt: now,
|
||||
actorUserId: userId,
|
||||
fromYardId: boardYardId ?? null,
|
||||
toYardId: booking.destinationYardId ?? null,
|
||||
trainScheduleId: schedule.id,
|
||||
trainId: wagon.trainId ?? null,
|
||||
bookingId: booking.id,
|
||||
toValue: wagon.trainId ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available,
|
||||
metadata: { slotId: slot.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
|
||||
/**
|
||||
* Message wording for the schedule-related customer notices: every one must
|
||||
* quote the SCHEDULE's train + voyage numbers, and reschedules must carry the
|
||||
* staff-entered reason instead of a hard-coded "for maintenance".
|
||||
*/
|
||||
describe('BookingNotifierService messages', () => {
|
||||
const schedule = { trainNumber: '8001', voyageNumber: 'V-117' };
|
||||
const booking = { id: 'b1', reference: 'BK-2026-000928', companyId: 'c1' } as never;
|
||||
const departure = new Date('2026-09-01T05:00:00.000Z');
|
||||
|
||||
let sent: string[];
|
||||
let inbox: string[];
|
||||
let service: BookingNotifierService;
|
||||
|
||||
beforeEach(() => {
|
||||
sent = [];
|
||||
inbox = [];
|
||||
const notifications = {
|
||||
directSend: jest.fn(async (_m: string, _to: string, msg: string) => {
|
||||
sent.push(msg);
|
||||
}),
|
||||
};
|
||||
const inboxSvc = {
|
||||
notify: jest.fn(async (input: { body: string }) => {
|
||||
inbox.push(input.body);
|
||||
}),
|
||||
};
|
||||
const trainSchedules = {
|
||||
findByIdWithStations: jest.fn(async () => ({ ...schedule, reference: 'S-2026-00012' })),
|
||||
};
|
||||
// Company contact lookup goes through raw SQL; return one phone + email.
|
||||
const dataSource = {
|
||||
query: jest.fn(async () => [{ phone: '+251900000000', email: 'ops@example.com' }]),
|
||||
};
|
||||
service = new BookingNotifierService(
|
||||
notifications as never,
|
||||
inboxSvc as never,
|
||||
trainSchedules as never,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
const flush = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
it('maintenance reschedule quotes train, voyage and the staff reason', async () => {
|
||||
service.maintenanceMoved(booking, departure, schedule, 'Locomotive maintenance.');
|
||||
await flush();
|
||||
expect(inbox[0]).toBe(
|
||||
'Train 8001 (voyage V-117) for booking BK-2026-000928 was rescheduled — reason: Locomotive maintenance. ' +
|
||||
'New departure date: 01/09/2026.',
|
||||
);
|
||||
});
|
||||
|
||||
it('maintenance reschedule falls back to "for maintenance" without a reason', async () => {
|
||||
service.maintenanceMoved(booking, departure, schedule, ' ');
|
||||
await flush();
|
||||
expect(inbox[0]).toContain('was rescheduled for maintenance. New departure date');
|
||||
});
|
||||
|
||||
it('plain reschedule carries the reason and the run label', async () => {
|
||||
service.rescheduled(booking, departure, schedule, 'Crew change');
|
||||
await flush();
|
||||
expect(inbox[0]).toBe(
|
||||
'Booking BK-2026-000928 on train 8001 (voyage V-117) has been rescheduled — reason: Crew change. ' +
|
||||
'New departure date: 01/09/2026.',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves the run label from a schedule id when only the id is known', async () => {
|
||||
service.scheduleCancelled(booking, 'sched-1');
|
||||
await flush();
|
||||
expect(inbox[0]).toMatch(/^Train 8001 \(voyage V-117\) for booking BK-2026-000928 has been cancelled/);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,21 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
|
||||
import { resolveCompanyNotifyContact } from '../notifications/resolve-company-phone.util';
|
||||
import { resolveShippingLineNotifyTarget } from '../notifications/resolve-shipping-line-contact.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { trainRunLabel, type TrainRunSource } from '../train-schedules/train-run-label.util';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
const capitalize = (text: string): string => text.charAt(0).toUpperCase() + text.slice(1);
|
||||
|
||||
/**
|
||||
* " — reason: Locomotive maintenance" for the staff-entered reschedule reason,
|
||||
* or '' when none was given. Trailing punctuation is trimmed so the sentence's
|
||||
* own full stop follows cleanly.
|
||||
*/
|
||||
const reasonClause = (reason?: string | null): string => {
|
||||
const text = reason?.trim().replace(/[.\s]+$/, '');
|
||||
return text ? ` — reason: ${text}` : '';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BookingNotifierService {
|
||||
private readonly logger = new Logger(BookingNotifierService.name);
|
||||
@@ -30,8 +43,9 @@ export class BookingNotifierService {
|
||||
|
||||
/**
|
||||
* Human-readable description of a train schedule for customer messages:
|
||||
* reference (or train number) + route + departure date. Never leaks a UUID —
|
||||
* falls back to a generic phrase when the schedule can't be loaded.
|
||||
* train number + voyage number (both the SCHEDULE's own — see trainRunLabel),
|
||||
* then reference, route and departure date. Never leaks a UUID — falls back
|
||||
* to a generic phrase when the schedule can't be loaded.
|
||||
*/
|
||||
private async scheduleLabel(scheduleId?: string | null): Promise<string> {
|
||||
const fallback = 'your selected train';
|
||||
@@ -39,8 +53,10 @@ export class BookingNotifierService {
|
||||
try {
|
||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||
if (!s) return fallback;
|
||||
// Customers know the train by its operating number (8001), not the
|
||||
// schedule reference — lead with it and keep S-… as the secondary id.
|
||||
// Customers know the departure by its train number (8001) and voyage
|
||||
// number, not the schedule reference — lead with those and keep S-… as
|
||||
// the secondary id.
|
||||
const run = trainRunLabel(s);
|
||||
const parts = [
|
||||
s.reference,
|
||||
s.originStation?.label && s.destinationStation?.label
|
||||
@@ -59,9 +75,9 @@ export class BookingNotifierService {
|
||||
hour12: false,
|
||||
})} EAT`
|
||||
: '';
|
||||
const number = s.trainNumber ?? s.reference ?? null;
|
||||
return number
|
||||
? `train ${number}${number === s.reference ? '' : detail}${departure}`
|
||||
if (run) return `${run}${detail}${departure}`;
|
||||
return s.reference
|
||||
? `train ${s.reference}${departure}`
|
||||
: `${fallback}${detail}${departure}`;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
@@ -71,6 +87,51 @@ export class BookingNotifierService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "train 8001 (voyage V-117)" for the departure a message is about, or null
|
||||
* when nothing is known. Accepts the schedule row itself (preferred — callers
|
||||
* that have just cancelled or detached the booking still hold it) or its id,
|
||||
* falling back to the booking's own train_schedule_id. Never throws: a label
|
||||
* lookup must not stop a notification going out.
|
||||
*/
|
||||
private async trainRun(
|
||||
b: Booking,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): Promise<string | null> {
|
||||
if (schedule && typeof schedule !== 'string') return trainRunLabel(schedule);
|
||||
const scheduleId = schedule ?? b.trainScheduleId ?? null;
|
||||
if (!scheduleId) return null;
|
||||
try {
|
||||
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||
return trainRunLabel(s);
|
||||
} catch (err) {
|
||||
this.logger.warn(`trainRun(${scheduleId}) failed: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the run label, then build and send the SMS/email + in-app item.
|
||||
* Fire-and-forget like every notifier method; `build` receives the label
|
||||
* (null when unknown) and returns the message text.
|
||||
*/
|
||||
private withRun(
|
||||
b: Booking,
|
||||
schedule: TrainRunSource | string | null | undefined,
|
||||
logLabel: string,
|
||||
title: string,
|
||||
build: (run: string | null) => string,
|
||||
opts: { contact?: boolean; inApp?: Partial<NotifyInput> } = {},
|
||||
): void {
|
||||
void (async () => {
|
||||
const msg = build(await this.trainRun(b, schedule));
|
||||
if (opts.contact !== false) await this.notifyContact(b, msg, logLabel);
|
||||
this.inApp(b, title, msg, opts.inApp);
|
||||
})().catch((err) =>
|
||||
this.logger.warn(`${logLabel} notification failed for ${this.ref(b)}: ${(err as Error).message}`),
|
||||
);
|
||||
}
|
||||
|
||||
private ref(b: Booking): string {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
@@ -162,21 +223,30 @@ export class BookingNotifierService {
|
||||
}
|
||||
|
||||
/** Train carrying the booking departed — dispatched origin → destination. */
|
||||
dispatched(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
dispatched(
|
||||
b: Booking,
|
||||
origin: string | null,
|
||||
destination: string | null,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): void {
|
||||
this.withRun(b, schedule, 'DISPATCHED', 'Shipment dispatched', (run) =>
|
||||
`Your booking ${b.reference ?? b.id} has been dispatched` +
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'DISPATCHED');
|
||||
this.inApp(b, 'Shipment dispatched', msg);
|
||||
`${origin || destination ? ` from ${origin ?? '?'} to ${destination ?? '?'}` : ''}` +
|
||||
`${run ? ` on ${run}` : ''}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Train carrying the booking arrived at destination. */
|
||||
arrived(b: Booking, origin: string | null, destination: string | null): void {
|
||||
const msg =
|
||||
`Your booking ${b.reference ?? b.id} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`;
|
||||
void this.notifyContact(b, msg, 'ARRIVED');
|
||||
this.inApp(b, 'Shipment arrived', msg);
|
||||
arrived(
|
||||
b: Booking,
|
||||
origin: string | null,
|
||||
destination: string | null,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): void {
|
||||
this.withRun(b, schedule, 'ARRIVED', 'Shipment arrived', (run) =>
|
||||
`Your booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has arrived` +
|
||||
`${destination ? ` at ${destination}` : ''}${origin ? ` (from ${origin})` : ''}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async payNow(b: Booking, deadline: Date): Promise<void> {
|
||||
@@ -318,21 +388,28 @@ export class BookingNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
this.inApp(b, 'Booking displaced', msg);
|
||||
displaced(b: Booking, schedule?: TrainRunSource | string | null): void {
|
||||
this.withRun(b, schedule, 'DISPLACED', 'Booking displaced', (run) =>
|
||||
`Booking ${b.reference ?? b.id} was displaced${run ? ` from ${run}` : ''} by a government booking. ` +
|
||||
`Move to another schedule or cancel.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff rescheduled the train carrying this booking to a new departure date.
|
||||
* The booking stays on the train — only the date moved.
|
||||
*/
|
||||
rescheduled(b: Booking, newDeparture: Date): void {
|
||||
rescheduled(
|
||||
b: Booking,
|
||||
newDeparture: Date,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
reason?: string | null,
|
||||
): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg = `Booking ${b.reference ?? b.id} has been rescheduled. New departure date: ${when}.`;
|
||||
void this.notifyContact(b, msg, 'RESCHEDULED');
|
||||
this.inApp(b, 'Booking rescheduled', msg);
|
||||
this.withRun(b, schedule, 'RESCHEDULED', 'Booking rescheduled', (run) =>
|
||||
`Booking ${b.reference ?? b.id}${run ? ` on ${run}` : ''} has been rescheduled` +
|
||||
`${reasonClause(reason)}. New departure date: ${when}.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,49 +417,75 @@ export class BookingNotifierService {
|
||||
* the customer's original choice. In-app only — staff drove the change and
|
||||
* the allocation itself already notifies through the secured path.
|
||||
*/
|
||||
allocatedOtherDay(b: Booking, newDeparture: Date): void {
|
||||
allocatedOtherDay(
|
||||
b: Booking,
|
||||
newDeparture: Date,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` +
|
||||
`New departure date: ${when}.`;
|
||||
this.inApp(b, 'Booking allocated to another date', msg);
|
||||
this.withRun(
|
||||
b,
|
||||
schedule,
|
||||
'ALLOCATED OTHER DAY',
|
||||
'Booking allocated to another date',
|
||||
(run) =>
|
||||
`Booking ${b.reference ?? b.id} has been allocated to ${run ?? 'a train'} on a different date. ` +
|
||||
`New departure date: ${when}.`,
|
||||
{ contact: false },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking was removed from its train during a staff reschedule (not a government
|
||||
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||
*/
|
||||
removedFromTrain(b: Booking): void {
|
||||
const msg =
|
||||
`Booking ${b.reference ?? b.id} has been removed from its train during rescheduling. ` +
|
||||
`Please rebook or select a new schedule from the portal.`;
|
||||
void this.notifyContact(b, msg, 'REMOVED FROM TRAIN');
|
||||
this.inApp(b, 'Removed from train', msg);
|
||||
removedFromTrain(b: Booking, schedule?: TrainRunSource | string | null): void {
|
||||
this.withRun(b, schedule, 'REMOVED FROM TRAIN', 'Removed from train', (run) =>
|
||||
`Booking ${b.reference ?? b.id} has been removed from ${run ?? 'its train'} during rescheduling. ` +
|
||||
`Please rebook or select a new schedule from the portal.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The train carrying this booking was cancelled. The booking is detached and
|
||||
* returns to the eligible pool — the customer must rebook or pick a new schedule.
|
||||
*/
|
||||
scheduleCancelled(b: Booking): void {
|
||||
const msg =
|
||||
`The train for booking ${b.reference ?? b.id} has been cancelled. ` +
|
||||
`Your booking is not lost — please rebook or select a new schedule from the portal.`;
|
||||
void this.notifyContact(b, msg, 'TRAIN CANCELLED');
|
||||
scheduleCancelled(b: Booking, schedule?: TrainRunSource | string | null): void {
|
||||
// HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email.
|
||||
this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH });
|
||||
this.withRun(
|
||||
b,
|
||||
schedule,
|
||||
'TRAIN CANCELLED',
|
||||
'Train cancelled',
|
||||
(run) =>
|
||||
`${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} has been cancelled. ` +
|
||||
`Your booking is not lost — please rebook or select a new schedule from the portal.`,
|
||||
{ inApp: { priority: NotificationPriority.HIGH } },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The train carrying this booking was moved for maintenance to a new departure
|
||||
* date. The booking stays on the train — only the date moved.
|
||||
* The train carrying this booking was moved (maintenance reschedule) to a new
|
||||
* departure date. The booking stays on the train — only the date moved. The
|
||||
* staff-entered reason is what the customer reads; "for maintenance" is only
|
||||
* the fallback when none was typed.
|
||||
*/
|
||||
maintenanceMoved(b: Booking, newDeparture: Date): void {
|
||||
maintenanceMoved(
|
||||
b: Booking,
|
||||
newDeparture: Date,
|
||||
schedule?: TrainRunSource | string | null,
|
||||
reason?: string | null,
|
||||
): void {
|
||||
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||
const msg =
|
||||
`The train for booking ${b.reference ?? b.id} was rescheduled for maintenance. ` +
|
||||
`New departure date: ${when}.`;
|
||||
void this.notifyContact(b, msg, 'MAINTENANCE RESCHEDULE');
|
||||
this.inApp(b, 'Train maintenance reschedule', msg);
|
||||
const why = reason?.trim() ? reasonClause(reason) : ' for maintenance';
|
||||
this.withRun(
|
||||
b,
|
||||
schedule,
|
||||
'MAINTENANCE RESCHEDULE',
|
||||
'Train rescheduled',
|
||||
(run) =>
|
||||
`${run ? capitalize(run) : 'The train'} for booking ${b.reference ?? b.id} was rescheduled${why}. ` +
|
||||
`New departure date: ${when}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { trainRunLabel } from '../train-schedules/train-run-label.util';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import {
|
||||
@@ -671,8 +672,11 @@ export class BookingWindowService implements OnModuleInit {
|
||||
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
// Name the departure by the schedule's train + voyage numbers (never the
|
||||
// built train's name) so customers can match it to yard/customs paperwork.
|
||||
const run = trainRunLabel(schedule);
|
||||
const msg =
|
||||
`Booking is now open for the train departing ${depart}. ` +
|
||||
`Booking is now open for ${run ?? 'the train'} departing ${depart}. ` +
|
||||
`Book your shipment from the portal home page before ${closes} EAT.`;
|
||||
|
||||
const seenPhone = new Set<string>();
|
||||
@@ -797,8 +801,9 @@ export class BookingWindowService implements OnModuleInit {
|
||||
const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
const run = trainRunLabel(schedule, { capitalize: true });
|
||||
const msgFor = (corridors: string[]) =>
|
||||
`A train is scheduled on your intercity corridor ${corridors.join(', ')}, ` +
|
||||
`${run ?? 'A train'} is scheduled on your intercity corridor ${corridors.join(', ')}, ` +
|
||||
`departing ${depart}. EDR will confirm once your cargo is placed on a train.`;
|
||||
|
||||
// One inbox item per booking (its `data` is the once-per-booking marker
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { TrainSchedulingService } from "./services/train-scheduling.service";
|
||||
|
||||
/**
|
||||
* Mid-corridor leave-behind. Logging a pass at station N means the train has
|
||||
* LEFT station N-1, so cargo that boarded back there has had its last chance
|
||||
* to load: anything the operator did not tick rides no further and is
|
||||
* unassigned back to the booking pool.
|
||||
*
|
||||
* Dispatch already does this for the origin yard; these cover the log-pass
|
||||
* twin, plus the structured payload the UI needs to offer the
|
||||
* EDR-fault / customer-fault cut on a part-loaded booking.
|
||||
*/
|
||||
describe("recordCheckpoint — mid-corridor leave-behind", () => {
|
||||
const STATIONS = [
|
||||
{ sequenceNo: 0, yardId: "yard-a", label: "Yard A" },
|
||||
{ sequenceNo: 1, yardId: "yard-b", label: "Yard B" },
|
||||
{ sequenceNo: 2, yardId: "yard-c", label: "Yard C" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Exercises the leave-behind block in isolation — the surrounding
|
||||
* recordCheckpoint does heavy graph/transaction work irrelevant here.
|
||||
*/
|
||||
const runLeaveBehind = async (
|
||||
dto: { sequenceNo: number; loadedBookingIds?: string[] },
|
||||
candidatesByYard: Record<string, string[]>,
|
||||
) => {
|
||||
const unassigned: Array<{ scheduleId: string; bookingId: string }> = [];
|
||||
const svc = Object.create(TrainSchedulingService.prototype) as {
|
||||
unloadedBoarderIdsAtYard(
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
): Promise<string[]>;
|
||||
unassignBooking(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<void>;
|
||||
};
|
||||
svc.unloadedBoarderIdsAtYard = async (
|
||||
_scheduleId: string,
|
||||
yardId: string,
|
||||
) => candidatesByYard[yardId] ?? [];
|
||||
svc.unassignBooking = async (scheduleId: string, bookingId: string) => {
|
||||
unassigned.push({ scheduleId, bookingId });
|
||||
};
|
||||
|
||||
// Mirrors the block inside recordCheckpoint.
|
||||
if (dto.loadedBookingIds && dto.sequenceNo > 0) {
|
||||
const departedYardId = STATIONS.find(
|
||||
(s) => s.sequenceNo === dto.sequenceNo - 1,
|
||||
)?.yardId;
|
||||
if (departedYardId) {
|
||||
const keep = new Set(dto.loadedBookingIds);
|
||||
const candidates = await svc.unloadedBoarderIdsAtYard(
|
||||
"sched-1",
|
||||
departedYardId,
|
||||
);
|
||||
for (const bookingId of candidates.filter((id) => !keep.has(id))) {
|
||||
await svc.unassignBooking("sched-1", bookingId, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
return unassigned.map((u) => u.bookingId);
|
||||
};
|
||||
|
||||
it("drops the unticked boarders of the yard the train just left", async () => {
|
||||
// b4 and b5 boarded at Yard B; only b5 was loaded. Logging Yard C means
|
||||
// the train has left B, so b4 is stranded and comes off the train.
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2, loadedBookingIds: ["b5"] },
|
||||
{ "yard-b": ["b4", "b5"] },
|
||||
);
|
||||
expect(dropped).toEqual(["b4"]);
|
||||
});
|
||||
|
||||
it("scopes the drop to the DEPARTED yard, never the one being logged", async () => {
|
||||
// Cargo boarding at Yard C is not due until the train is there — logging
|
||||
// the pass at C must not shed it.
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2, loadedBookingIds: [] },
|
||||
{ "yard-b": [], "yard-c": ["b6", "b7"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves nobody behind when the client omits the list", async () => {
|
||||
// Older clients send no list — the historic behavior is that everyone rides.
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2 },
|
||||
{ "yard-b": ["b4"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not shed at the origin — that is dispatch's decision", async () => {
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 0, loadedBookingIds: [] },
|
||||
{ "yard-a": ["b1", "b3"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every ticked booking on the train", async () => {
|
||||
const dropped = await runLeaveBehind(
|
||||
{ sequenceNo: 2, loadedBookingIds: ["b4", "b5"] },
|
||||
{ "yard-b": ["b4", "b5"] },
|
||||
);
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNoPartiallyLoadedBookings — structured payload", () => {
|
||||
const makeService = (
|
||||
rows: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
loaded: string;
|
||||
total: string;
|
||||
unloadedAllocationIds: string[];
|
||||
}>,
|
||||
) => {
|
||||
const svc = Object.create(TrainSchedulingService.prototype) as {
|
||||
dataSource: {
|
||||
query: (sql: string, params: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
assertNoPartiallyLoadedBookings(
|
||||
schedule: unknown,
|
||||
boardingYardId: string,
|
||||
context: { action: string; yardLabel?: string },
|
||||
): Promise<void>;
|
||||
};
|
||||
svc.dataSource = { query: async () => rows };
|
||||
return svc;
|
||||
};
|
||||
const schedule = {
|
||||
id: "sched-1",
|
||||
trainSetId: "set-1",
|
||||
originStationId: "yard-a",
|
||||
};
|
||||
|
||||
it("carries the never-loaded allocation ids the fault-cut modal needs", async () => {
|
||||
const svc = makeService([
|
||||
{
|
||||
bookingId: "b4",
|
||||
reference: "BK-2026-000853",
|
||||
loaded: "1",
|
||||
total: "4",
|
||||
unloadedAllocationIds: ["w2", "w3", "w4"],
|
||||
},
|
||||
]);
|
||||
|
||||
const err = await svc
|
||||
.assertNoPartiallyLoadedBookings(schedule, "yard-b", {
|
||||
action: "record this checkpoint",
|
||||
yardLabel: "Yard B",
|
||||
})
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(BadRequestException);
|
||||
const body = (err as BadRequestException).getResponse() as {
|
||||
code: string;
|
||||
message: string;
|
||||
partiallyLoaded: {
|
||||
yardLabel: string | null;
|
||||
bookings: Array<{
|
||||
bookingId: string;
|
||||
loadedWagons: number;
|
||||
totalWagons: number;
|
||||
unloadedAllocationIds: string[];
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.code).toBe("PARTIALLY_LOADED_BOOKINGS");
|
||||
expect(body.partiallyLoaded.yardLabel).toBe("Yard B");
|
||||
expect(body.partiallyLoaded.bookings).toEqual([
|
||||
{
|
||||
bookingId: "b4",
|
||||
reference: "BK-2026-000853",
|
||||
loadedWagons: 1,
|
||||
totalWagons: 4,
|
||||
unloadedAllocationIds: ["w2", "w3", "w4"],
|
||||
},
|
||||
]);
|
||||
// The prose message survives for logs and older clients.
|
||||
expect(body.message).toContain("BK-2026-000853 (1/4 wagons loaded)");
|
||||
});
|
||||
|
||||
it("stays silent when nothing at the yard is half-loaded", async () => {
|
||||
const svc = makeService([]);
|
||||
await expect(
|
||||
svc.assertNoPartiallyLoadedBookings(schedule, "yard-b", {
|
||||
action: "dispatch",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch's origin auto-load. This UPDATE is the reason an unticked booking
|
||||
* could still end up marked loaded: it stamps every PAID origin boarder, so
|
||||
* without the confirmed-list guard a booking left attached (or one the
|
||||
* unassign predicate cannot shed) rides as if its cargo were aboard.
|
||||
*/
|
||||
describe('dispatchSchedule — origin auto-load respects the confirmed list', () => {
|
||||
/** Mirrors the `($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))` guard. */
|
||||
const wouldAutoLoad = (bookingId: string, confirmed: string[] | undefined) =>
|
||||
confirmed === undefined || confirmed.includes(bookingId);
|
||||
|
||||
it('stamps only the ticked bookings', () => {
|
||||
expect(wouldAutoLoad('b2', ['b2'])).toBe(true);
|
||||
expect(wouldAutoLoad('b1', ['b2'])).toBe(false);
|
||||
});
|
||||
|
||||
it('stamps nobody when the operator unticks everyone', () => {
|
||||
expect(wouldAutoLoad('b1', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the historic auto-load for clients that send no list', () => {
|
||||
expect(wouldAutoLoad('b1', undefined)).toBe(true);
|
||||
expect(wouldAutoLoad('b2', undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Empty wagons must travel with their train.
|
||||
*
|
||||
* The checkpoint position fix moves wagons by `current_train_schedule_id`, but
|
||||
* dispatch used to bind only the PINNED slots (the ones carrying cargo). A
|
||||
* built train rolls with its whole consist, so every empty wagon coupled to it
|
||||
* was left unbound — and stayed recorded at the origin yard while the train it
|
||||
* is hooked to travelled the corridor.
|
||||
*/
|
||||
describe('dispatchSchedule — the whole consist travels, not just loaded slots', () => {
|
||||
/** Mirrors dispatch's binding set: pinned slots ∪ built-train consist. */
|
||||
const boundAtDispatch = (
|
||||
pinnedSlotWagonIds: Array<string | null>,
|
||||
builtTrainWagonIds: string[],
|
||||
) => [
|
||||
...new Set([
|
||||
...pinnedSlotWagonIds.filter((id): id is string => Boolean(id)),
|
||||
...builtTrainWagonIds,
|
||||
]),
|
||||
];
|
||||
|
||||
it('binds the empty wagons coupled to the built train', () => {
|
||||
// The real shape of the reported schedule: 3 slots carry cargo, 45 empties
|
||||
// ride along. All 48 must move when a checkpoint is logged.
|
||||
const pinned = ['w1', 'w2', 'w3'];
|
||||
const consist = ['w1', 'w2', 'w3', 'e1', 'e2', 'e3'];
|
||||
const bound = boundAtDispatch(pinned, consist);
|
||||
expect(bound).toEqual(['w1', 'w2', 'w3', 'e1', 'e2', 'e3']);
|
||||
expect(bound).toContain('e1');
|
||||
});
|
||||
|
||||
it('never double-binds a wagon that is both pinned and on the train', () => {
|
||||
const bound = boundAtDispatch(['w1', 'w1'], ['w1']);
|
||||
expect(bound).toEqual(['w1']);
|
||||
});
|
||||
|
||||
it('still binds pinned slots when there is no built train', () => {
|
||||
// A set-only schedule (no Train row) has no consist to add.
|
||||
expect(boundAtDispatch(['w1', null, 'w2'], [])).toEqual(['w1', 'w2']);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -119,6 +122,19 @@ export class CreateContainerTrainScheduleDto {
|
||||
@IsDateString()
|
||||
scheduleDate!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'V-2026-0620',
|
||||
maxLength: 20,
|
||||
description:
|
||||
'Voyage (sailing) number for this departure — the run identifier yards and ' +
|
||||
'customs quote. Required at creation; the UI pre-fills it with the built ' +
|
||||
"train's direction-matched run number, but staff may override it.",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'A voyage number is required' })
|
||||
@MaxLength(20)
|
||||
voyageNumber!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { TrainCheckpointKind } from '@edr/types';
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { TrainCheckpointKind } from "@edr/types";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
} from "class-validator";
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@ApiProperty({
|
||||
description: "Station position along the route (0 = origin).",
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sequenceNo!: number;
|
||||
@@ -31,7 +33,7 @@ export class RecordCheckpointDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
|
||||
"ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -42,7 +44,10 @@ export class RecordCheckpointDto {
|
||||
* loading and unloading time. All optional: a stop logged without them still
|
||||
* records its staying time.
|
||||
*/
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; unloading start.' })
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: "ISO timestamp; unloading start.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
unloadingStartedAt?: string;
|
||||
@@ -62,6 +67,24 @@ export class RecordCheckpointDto {
|
||||
@IsISO8601()
|
||||
loadingCompletedAt?: string;
|
||||
|
||||
/**
|
||||
* Mid-corridor leave-behind, the log-pass twin of DispatchScheduleDto's field.
|
||||
* Recording THIS station means the train left the previous one, so the
|
||||
* bookings that boarded back there have had their last chance to load. When
|
||||
* present, only these ride on; every other unloaded boarder of the departed
|
||||
* yard is deallocated from its wagon and returned to the booking pool.
|
||||
* Absent (older clients) = nobody is left behind, the historic behavior.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
"Bookings from the yard just departed confirmed loaded; the rest are unassigned back to the pool. Omit to leave nobody behind.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID("4", { each: true })
|
||||
loadedBookingIds?: string[];
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -73,7 +96,8 @@ export class RecordCheckpointDto {
|
||||
export class UpdateCheckpointDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
|
||||
description:
|
||||
"ISO timestamp. Past allowed, future rejected, must be in corridor order.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -110,7 +134,8 @@ export class UpdateCheckpointDto {
|
||||
export class DispatchScheduleDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
|
||||
description:
|
||||
"Actual departure time; defaults to now. Past allowed, future rejected.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -125,10 +150,10 @@ export class DispatchScheduleDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description:
|
||||
'Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.',
|
||||
"Origin-yard bookings confirmed loaded; the rest are unassigned back to the pool. Omit to auto-load all.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
@IsUUID("4", { each: true })
|
||||
loadedBookingIds?: string[];
|
||||
}
|
||||
|
||||
@@ -439,6 +439,7 @@ export class IntercityService {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
status: booking.status,
|
||||
paymentStatus: booking.paymentStatus ?? null,
|
||||
freightType: booking.freightType,
|
||||
isGovernment: booking.isGovernment,
|
||||
customer: booking.company?.name ?? 'Unknown customer',
|
||||
|
||||
@@ -508,6 +508,7 @@ describe('TrainSchedulingService', () => {
|
||||
const result = await service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: futureDeparture,
|
||||
voyageNumber: 'V-TEST-1',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
});
|
||||
|
||||
@@ -610,6 +611,7 @@ describe('TrainSchedulingService', () => {
|
||||
service.createContainerTrainSchedule({
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
voyageNumber: 'V-TEST-2',
|
||||
locomotiveIds: ['loc-1', 'loc-2'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
@@ -1062,6 +1064,7 @@ describe('TrainSchedulingService', () => {
|
||||
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||||
sequenceNo,
|
||||
wagonNumber,
|
||||
physicalWagonId: `wagon-id-${wagonNumber}`,
|
||||
physicalWagon: { wagonNumber },
|
||||
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||||
lengthMeters: 14,
|
||||
@@ -1185,6 +1188,57 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a slot with no physical wagon pinned from the import document too', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
trainNumber: '7002',
|
||||
route: 'DCT/SGTD → GMP',
|
||||
origin: 'DCT/SGTD',
|
||||
destination: 'GMP',
|
||||
totalBookings: 2,
|
||||
wagons: [
|
||||
{
|
||||
sequenceNo: 1,
|
||||
// No physical wagon pinned (fleet shortfall, or a REAL cut nulled
|
||||
// it out) — nothing physical to marshal, even though the slot
|
||||
// still carries a LOADED allocation.
|
||||
wagonNumber: null,
|
||||
boardYard: null,
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'GHOST-001' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sequenceNo: 2,
|
||||
wagonNumber: 'W-IMP',
|
||||
boardYard: null,
|
||||
alightYard: null,
|
||||
allocations: [
|
||||
{
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
operation: { status: {} },
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(html).not.toContain('GHOST-001');
|
||||
expect(html).toContain('W-IMP');
|
||||
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a leg slot entirely from the export document — not part of the departing consist', () => {
|
||||
const sizedAllocation = {
|
||||
...loadedAllocation,
|
||||
@@ -1211,6 +1265,29 @@ describe('TrainSchedulingService', () => {
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('drops a whole-route slot with no physical wagon pinned from the export document too', () => {
|
||||
const sizedAllocation = {
|
||||
...loadedAllocation,
|
||||
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
|
||||
};
|
||||
const ghost = { ...makeWagon(2, 'W-GHOST', [sizedAllocation]), id: 'slot-ghost', physicalWagonId: null };
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, ghost] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule, {});
|
||||
|
||||
expect(html).not.toContain('W-GHOST');
|
||||
expect(html).toContain('<span>Wagons</span><strong>1</strong>');
|
||||
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
|
||||
});
|
||||
|
||||
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
@@ -1444,6 +1521,22 @@ describe('TrainSchedulingService', () => {
|
||||
expect(numbers).toEqual(['W-LEG2']);
|
||||
});
|
||||
|
||||
it('drops a whole-route slot with no physical wagon pinned, even though its allocation is LOADED', () => {
|
||||
// A booking can hold a LOADED allocation before a real wagon backs it
|
||||
// (fleet shortfall left the slot unpinned), or a REAL cut nulls
|
||||
// physicalWagonId without ever touching the slot's own status. Either
|
||||
// way there is no physical wagon standing there to marshal.
|
||||
const pinned = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]);
|
||||
const ghost = { ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), physicalWagonId: null };
|
||||
const schedule = { trainSet: { wagons: [pinned, ghost] }, scheduleBookings: [] };
|
||||
|
||||
const { wagons } = onBoardView(schedule);
|
||||
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||
(w) => w.physicalWagon.wagonNumber,
|
||||
);
|
||||
expect(numbers).toEqual(['W-001']);
|
||||
});
|
||||
|
||||
it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => {
|
||||
// Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded
|
||||
// at Adama, a LATER stop) read identically to intercityOnBoardView by
|
||||
@@ -1860,6 +1953,8 @@ describe('TrainSchedulingService', () => {
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn((x: unknown) => x),
|
||||
})),
|
||||
// Wagon-history lookup of the released allocations' physical wagons.
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonAllocationSnapshot,
|
||||
WagonEventType,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
@@ -72,6 +73,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
||||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||
@@ -422,8 +424,28 @@ export class TrainSchedulingService {
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService?: BookingBatchService,
|
||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||
// spec constructors working; production always has it.
|
||||
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
||||
) {}
|
||||
|
||||
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
||||
private async wagonsOfAllocations(
|
||||
manager: EntityManager,
|
||||
allocationIds: string[],
|
||||
): Promise<Array<{ allocationId: string; wagonId: string; wagonNumber: string; yardId: string | null; trainId: string | null }>> {
|
||||
if (!allocationIds.length) return [];
|
||||
return manager.query(
|
||||
`SELECT a.id AS "allocationId", w.id AS "wagonId", w.wagon_number AS "wagonNumber",
|
||||
w.current_yard_id AS "yardId", w.train_id AS "trainId"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
WHERE a.id = ANY($1::uuid[])`,
|
||||
[allocationIds],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify each booking's customer that their shipment was dispatched / arrived,
|
||||
* with a deep-link to the booking. Fire-and-forget — never blocks the action.
|
||||
@@ -443,8 +465,11 @@ export class TrainSchedulingService {
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const b of bookings) {
|
||||
if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
|
||||
else this.bookingNotifier.arrived(b, origin, destination);
|
||||
if (event === 'dispatched') {
|
||||
this.bookingNotifier.dispatched(b, origin, destination, schedule);
|
||||
} else {
|
||||
this.bookingNotifier.arrived(b, origin, destination, schedule);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
|
||||
@@ -1170,7 +1195,7 @@ export class TrainSchedulingService {
|
||||
});
|
||||
for (const booking of allocatedBookings) {
|
||||
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
|
||||
this.bookingNotifier.rescheduled(booking, departure);
|
||||
this.bookingNotifier.rescheduled(booking, departure, schedule);
|
||||
notifiedCount += 1;
|
||||
}
|
||||
}
|
||||
@@ -1375,7 +1400,7 @@ export class TrainSchedulingService {
|
||||
.getRepository(Booking)
|
||||
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
|
||||
for (const booking of aboard) {
|
||||
this.bookingNotifier.maintenanceMoved(booking, departure);
|
||||
this.bookingNotifier.maintenanceMoved(booking, departure, schedule, dto.reason);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1872,6 +1897,10 @@ export class TrainSchedulingService {
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
direction,
|
||||
trainNumber: pairTrainNumber ?? undefined,
|
||||
// Staff-entered at creation; the UI defaults it to the built train's
|
||||
// own voyage number (Train.trainName). Fall back to the pair train
|
||||
// number here only for non-UI callers that send none.
|
||||
voyageNumber: dto.voyageNumber?.trim() || pairTrainNumber || null,
|
||||
maxWagons,
|
||||
plannedWagonYards,
|
||||
reverseWagonOrder: dto.reverseWagonOrder ?? false,
|
||||
@@ -2415,6 +2444,21 @@ export class TrainSchedulingService {
|
||||
manager,
|
||||
);
|
||||
await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
|
||||
const carried = await this.wagonsOfAllocations(manager, allocationIds);
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
carried.map((c) => ({
|
||||
wagonId: c.wagonId,
|
||||
wagonNumber: c.wagonNumber,
|
||||
type: WagonEventType.BookingUnassigned,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: c.yardId,
|
||||
trainId: c.trainId,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId,
|
||||
metadata: { allocationId: c.allocationId },
|
||||
})),
|
||||
);
|
||||
await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
|
||||
}
|
||||
|
||||
@@ -2481,6 +2525,18 @@ export class TrainSchedulingService {
|
||||
trainSetWagonId: null,
|
||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
});
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.ReleasedFromSchedule,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
reason: 'Booking unassigned from the dispatched train',
|
||||
});
|
||||
}
|
||||
}
|
||||
await manager.getRepository(TrainSetWagon).delete(slot.id);
|
||||
@@ -2531,7 +2587,8 @@ export class TrainSchedulingService {
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId }, relations: { company: true } });
|
||||
if (removedBooking && opts.notifyCustomer !== false) {
|
||||
this.bookingNotifier.removedFromTrain(removedBooking);
|
||||
// The booking's train_schedule_id is already cleared — name the run explicitly.
|
||||
this.bookingNotifier.removedFromTrain(removedBooking, schedule);
|
||||
}
|
||||
this.logger.log(
|
||||
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
|
||||
@@ -2823,10 +2880,34 @@ export class TrainSchedulingService {
|
||||
|
||||
// The pin lives ONLY on the schedule's slot — the Wagon entity keeps
|
||||
// its status untouched so other schedules can still use the wagon.
|
||||
const previousPinId = slotById.get(assignment.trainSetWagonId)?.physicalWagonId ?? null;
|
||||
await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
|
||||
physicalWagonId: assignment.physicalWagonId,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
if (previousPinId !== assignment.physicalWagonId) {
|
||||
const pinEvents: WagonEventInput[] = [
|
||||
{
|
||||
wagonId: assignment.physicalWagonId,
|
||||
type: WagonEventType.PinnedToSchedule,
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId ?? null,
|
||||
fromYardId: schedule.originStationId ?? null,
|
||||
metadata: { slotId: assignment.trainSetWagonId, auto: false },
|
||||
},
|
||||
];
|
||||
if (previousPinId) {
|
||||
pinEvents.push({
|
||||
wagonId: previousPinId,
|
||||
type: WagonEventType.UnpinnedFromSchedule,
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId ?? null,
|
||||
reason: 'Replaced on the slot',
|
||||
metadata: { slotId: assignment.trainSetWagonId },
|
||||
});
|
||||
}
|
||||
await this.wagonHistory?.record(manager, pinEvents);
|
||||
}
|
||||
for (const [physicalId, slotId] of slotIdByPhysicalId) {
|
||||
if (slotId === assignment.trainSetWagonId) {
|
||||
slotIdByPhysicalId.delete(physicalId);
|
||||
@@ -2992,9 +3073,27 @@ export class TrainSchedulingService {
|
||||
}
|
||||
// The train is out — every pinned wagon is ASSIGNED to this schedule and
|
||||
// stays pinned so no other schedule can pick it while it's rolling.
|
||||
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||||
const pinnedDispatchIds = (schedule.trainSet?.wagons ?? [])
|
||||
.map((slot) => slot.physicalWagonId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
// A built train rolls with its WHOLE consist, not just the slots that
|
||||
// carry cargo: an empty wagon coupled to the train is physically leaving
|
||||
// the yard too. Binding only the pinned slots left those empties behind
|
||||
// on `current_train_schedule_id`, so the checkpoint position fix (which
|
||||
// filters on exactly that column) never moved them and they stayed
|
||||
// recorded at the origin yard while the train they are hooked to
|
||||
// travelled the corridor.
|
||||
const consistPhysicalIds = schedule.trainSet?.trainId
|
||||
? (
|
||||
await manager.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((w) => w.id)
|
||||
: [];
|
||||
const dispatchedPhysicalIds = [
|
||||
...new Set([...pinnedDispatchIds, ...consistPhysicalIds]),
|
||||
];
|
||||
if (dispatchedPhysicalIds.length) {
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
@@ -3002,6 +3101,25 @@ export class TrainSchedulingService {
|
||||
{ id: In(dispatchedPhysicalIds) },
|
||||
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
|
||||
);
|
||||
const dispatchedWagons = await manager.getRepository(Wagon).find({
|
||||
where: { id: In(dispatchedPhysicalIds) },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true, trainId: true },
|
||||
});
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
dispatchedWagons.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.Dispatched,
|
||||
occurredAt: now,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
trainId: w.trainId ?? schedule.trainSet?.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Assigned,
|
||||
metadata: { destinationYardId: schedule.destinationStationId ?? null },
|
||||
})),
|
||||
);
|
||||
}
|
||||
// Planned couples boarding at the ORIGIN join the built train now — the
|
||||
// departure is the moment they are physically hooked on. Mid-route
|
||||
@@ -3039,6 +3157,32 @@ export class TrainSchedulingService {
|
||||
status: WagonStatus.Assigned,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
});
|
||||
await this.wagonHistory?.record(manager, [
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
occurredAt: now,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: dispatchTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: maxSeq,
|
||||
reason: 'Planned couple at the origin yard',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
},
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.Dispatched,
|
||||
occurredAt: now,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: dispatchTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Assigned,
|
||||
},
|
||||
]);
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
@@ -3064,6 +3208,15 @@ export class TrainSchedulingService {
|
||||
// that the operator didn't load individually are auto-loaded now — the
|
||||
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||||
// operator loads them at their own yard.
|
||||
//
|
||||
// When the client sends the confirmed list, loading is a MANUAL decision:
|
||||
// only the ticked bookings are stamped loaded. Anything unticked was
|
||||
// already unassigned above, but a booking can also sit here unticked and
|
||||
// still attached (government, or one this predicate cannot shed) — those
|
||||
// must not be auto-loaded, or an empty wagon rides as if it carried cargo.
|
||||
// Absent (older clients) = auto-load every origin boarder, the historic
|
||||
// behavior.
|
||||
const confirmedLoadedIds = dto.loadedBookingIds;
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = 'IN_TRANSIT',
|
||||
@@ -3075,8 +3228,14 @@ export class TrainSchedulingService {
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
||||
[scheduleId, schedule.originStationId, now],
|
||||
AND (b.payment_status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))
|
||||
AND ($4::uuid[] IS NULL OR b.id = ANY($4::uuid[]))`,
|
||||
[
|
||||
scheduleId,
|
||||
schedule.originStationId,
|
||||
now,
|
||||
confirmedLoadedIds ? confirmedLoadedIds : null,
|
||||
],
|
||||
);
|
||||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||||
await manager
|
||||
@@ -3174,11 +3333,19 @@ export class TrainSchedulingService {
|
||||
context: { action: string; yardLabel?: string },
|
||||
): Promise<void> {
|
||||
if (!schedule.trainSetId) return;
|
||||
const rows: Array<{ reference: string; loaded: string; total: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT b.reference,
|
||||
const rows: Array<{
|
||||
bookingId: string;
|
||||
reference: string;
|
||||
loaded: string;
|
||||
total: string;
|
||||
unloadedAllocationIds: string[];
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference,
|
||||
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
|
||||
COUNT(*) AS total
|
||||
COUNT(*) AS total,
|
||||
ARRAY_AGG(a.id) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED'))
|
||||
AS "unloadedAllocationIds"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.bookings b ON b.id = a.booking_id
|
||||
@@ -3190,18 +3357,38 @@ export class TrainSchedulingService {
|
||||
GROUP BY b.id, b.reference
|
||||
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
||||
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
||||
[schedule.trainSetId, boardingYardId],
|
||||
);
|
||||
[schedule.trainSetId, boardingYardId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const detail = rows
|
||||
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
||||
.join(', ');
|
||||
const where = context.yardLabel ? ` at ${context.yardLabel}` : '';
|
||||
throw new BadRequestException(
|
||||
`Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
|
||||
// The message stays human-readable for logs and older clients, but the
|
||||
// payload carries the machine-readable cut so the UI can offer the
|
||||
// EDR-fault / customer-fault decision instead of parsing prose.
|
||||
throw new BadRequestException({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
code: 'PARTIALLY_LOADED_BOOKINGS',
|
||||
message:
|
||||
`Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` +
|
||||
`or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` +
|
||||
`rebookable) first: ${detail}`,
|
||||
);
|
||||
partiallyLoaded: {
|
||||
scheduleId: schedule.id,
|
||||
boardingYardId,
|
||||
yardLabel: context.yardLabel ?? null,
|
||||
action: context.action,
|
||||
bookings: rows.map((r) => ({
|
||||
bookingId: r.bookingId,
|
||||
reference: r.reference,
|
||||
loadedWagons: Number(r.loaded),
|
||||
totalWagons: Number(r.total),
|
||||
unloadedAllocationIds: r.unloadedAllocationIds ?? [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3228,6 +3415,20 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The log-pass twin of {@link unloadedOriginBoarderIds}: bookings that boarded
|
||||
* at `yardId` and are still unloaded once the train has left it. Same
|
||||
* predicate — partially-loaded bookings (loading_started_at set) are excluded
|
||||
* because assertNoPartiallyLoadedBookings resolves those first, and government
|
||||
* bookings can never be shed.
|
||||
*/
|
||||
private async unloadedBoarderIdsAtYard(
|
||||
scheduleId: string,
|
||||
yardId: string,
|
||||
): Promise<string[]> {
|
||||
return this.unloadedOriginBoarderIds(scheduleId, yardId);
|
||||
}
|
||||
|
||||
private async unloadedOriginBoarderIds(
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
@@ -3244,7 +3445,7 @@ export class TrainSchedulingService {
|
||||
AND b.loading_started_at IS NULL
|
||||
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
||||
AND b.is_government = false
|
||||
AND (b.status = 'PAID'
|
||||
AND (b.payment_status = 'PAID'
|
||||
OR (b.shipping_line_company_id IS NOT NULL AND b.status = 'FULLY_EXECUTED'))`,
|
||||
[scheduleId, originYardId],
|
||||
);
|
||||
@@ -3358,7 +3559,7 @@ export class TrainSchedulingService {
|
||||
// milestone still counts as paid — the clearance views self-heal the row on
|
||||
// read, and the gate pass must not lag behind that.
|
||||
for (const booking of bookings) {
|
||||
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
|
||||
if (booking.paymentStatus === 'PAID') {
|
||||
paidBookingIds.add(booking.id);
|
||||
}
|
||||
}
|
||||
@@ -3602,6 +3803,13 @@ export class TrainSchedulingService {
|
||||
const wagons = (schedule.trainSet?.wagons ?? [])
|
||||
.filter((wagon) => {
|
||||
if (wagon.status === 'DEPARTED') return false;
|
||||
// No physical wagon pinned to the slot — a booking can hold an
|
||||
// allocation before a real wagon backs it (e.g. a fleet shortfall
|
||||
// left it unpinned). There is nothing physical here to marshal, and
|
||||
// a REAL cut also lands here: it nulls physicalWagonId without ever
|
||||
// touching this slot's own status, so a cut wagon would otherwise
|
||||
// linger as a phantom row with its cargo still listed.
|
||||
if (!wagon.physicalWagonId) return false;
|
||||
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
|
||||
return wagon.boardYardId == null || hasLoaded;
|
||||
})
|
||||
@@ -3930,6 +4138,11 @@ export class TrainSchedulingService {
|
||||
// Their own coupling shows up on THAT stop's own marshalling document.
|
||||
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
|
||||
.filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
|
||||
// No physical wagon pinned to the slot (fleet shortfall left a booking's
|
||||
// allocation unpinned, or a REAL cut nulled it out): nothing physical
|
||||
// to marshal, so no row. Harmless no-op for the numbered docs, whose
|
||||
// wagons list already went through intercityOnBoardView's own check.
|
||||
.filter((wagon) => Boolean(wagon.physicalWagonId))
|
||||
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
|
||||
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
|
||||
// slot recorded when they were loaded.
|
||||
@@ -4323,8 +4536,11 @@ export class TrainSchedulingService {
|
||||
// A leg slot (boardYard set) couples mid-corridor — it is not part of the
|
||||
// consist this Djibouti-side document is checked against yet, so it gets
|
||||
// no row and no count here at all. Its own coupling shows up on THAT
|
||||
// stop's own marshalling document once it actually happens.
|
||||
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard);
|
||||
// stop's own marshalling document once it actually happens. Same for a
|
||||
// slot with no physical wagon pinned at all — a booking can hold an
|
||||
// allocation before a real wagon backs it (fleet shortfall), or a REAL
|
||||
// cut nulled it out; either way there is nothing physical to marshal.
|
||||
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null);
|
||||
const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
@@ -4983,6 +5199,24 @@ export class TrainSchedulingService {
|
||||
// skipped checkpoint log cannot smuggle an unresolved yard past the gate.
|
||||
await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo);
|
||||
|
||||
// Mid-corridor leave-behind. Recording THIS station means the train has
|
||||
// left the previous one, so cargo that boarded back there has had its last
|
||||
// chance to load: anything the operator did not tick is deallocated and
|
||||
// returned to the pool, exactly as dispatch does for the origin yard.
|
||||
// Origin (seq 0) is dispatch's job, so only seq >= 1 has a departed yard.
|
||||
if (dto.loadedBookingIds && dto.sequenceNo > 0) {
|
||||
const departedYardId = stations.find(
|
||||
(s) => s.sequenceNo === dto.sequenceNo - 1,
|
||||
)?.yardId;
|
||||
if (departedYardId) {
|
||||
const keep = new Set(dto.loadedBookingIds);
|
||||
const candidates = await this.unloadedBoarderIdsAtYard(scheduleId, departedYardId);
|
||||
for (const bookingId of candidates.filter((id) => !keep.has(id))) {
|
||||
await this.unassignBooking(scheduleId, bookingId, undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
|
||||
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
||||
where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
|
||||
@@ -5069,6 +5303,7 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const adjustmentRows: ScheduleWagonAdjustmentLog[] = [];
|
||||
const movementRows: WagonMovement[] = [];
|
||||
const historyRows: WagonEventInput[] = [];
|
||||
let realCutHappened = false;
|
||||
for (const [wagonId, cutYardId] of cutNow) {
|
||||
const wagon = cutWagonById.get(wagonId);
|
||||
@@ -5076,6 +5311,31 @@ export class TrainSchedulingService {
|
||||
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||
if (realCutIds.has(wagonId) && builtTrainId) {
|
||||
// REAL cut: the built train permanently loses the wagon here.
|
||||
historyRows.push(
|
||||
{
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CutAtYard,
|
||||
occurredAt,
|
||||
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null,
|
||||
toYardId: cutYardId,
|
||||
trainId: builtTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Available,
|
||||
metadata: { permanent: true },
|
||||
},
|
||||
{
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
occurredAt,
|
||||
fromYardId: cutYardId,
|
||||
trainId: builtTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
fromValue: wagon.sequenceNumber,
|
||||
reason: 'Cut from the train at this yard (permanent)',
|
||||
},
|
||||
);
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
currentYardId: cutYardId,
|
||||
currentTrainScheduleId: null,
|
||||
@@ -5108,6 +5368,18 @@ export class TrainSchedulingService {
|
||||
realCutHappened = true;
|
||||
} else {
|
||||
// Soft cut: sits out the rest of this trip, stays in the build.
|
||||
historyRows.push({
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CutAtYard,
|
||||
occurredAt,
|
||||
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId ?? null,
|
||||
toYardId: cutYardId,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
metadata: { permanent: false },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagonId, {
|
||||
currentYardId: cutYardId,
|
||||
currentTrainScheduleId: null,
|
||||
@@ -5132,6 +5404,7 @@ export class TrainSchedulingService {
|
||||
if (movementRows.length) {
|
||||
await manager.getRepository(WagonMovement).save(movementRows);
|
||||
}
|
||||
await this.wagonHistory?.record(manager, historyRows);
|
||||
// Keep the coupling order gapless after permanent removals.
|
||||
if (realCutHappened && builtTrainId) {
|
||||
const remaining = await manager.getRepository(Wagon).find({
|
||||
@@ -5185,6 +5458,18 @@ export class TrainSchedulingService {
|
||||
status: WagonStatus.Assigned,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
});
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
occurredAt,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: builtTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: maxSeq,
|
||||
reason: 'Planned couple at a mid-route stop',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
});
|
||||
coupleLogRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
@@ -5202,6 +5487,20 @@ export class TrainSchedulingService {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows);
|
||||
}
|
||||
}
|
||||
// Which wagons the position fix below will actually move — read first
|
||||
// so each gets its own PASSED_CHECKPOINT history row (from → to yard).
|
||||
const riding = await manager
|
||||
.getRepository(Wagon)
|
||||
.createQueryBuilder('w')
|
||||
.select(['w.id', 'w.wagonNumber', 'w.currentYardId', 'w.trainId'])
|
||||
.where('w.current_train_schedule_id = :scheduleId', { scheduleId })
|
||||
.andWhere('(w.current_yard_id IS NULL OR w.current_yard_id IN (:...passedYardIds))', {
|
||||
passedYardIds,
|
||||
})
|
||||
.andWhere('w.current_yard_id IS DISTINCT FROM :stationYardId', {
|
||||
stationYardId: station.yardId,
|
||||
})
|
||||
.getMany();
|
||||
|
||||
// Leg slots (booking legs boarding/alighting mid-corridor — see
|
||||
// stampSlotLegs) reaching their board/alight yard here: logged same as
|
||||
@@ -5284,6 +5583,20 @@ export class TrainSchedulingService {
|
||||
passedYardIds,
|
||||
})
|
||||
.execute();
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
riding.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.PassedCheckpoint,
|
||||
occurredAt,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
toYardId: station.yardId,
|
||||
trainId: w.trainId ?? schedule.trainSet?.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
metadata: { sequenceNo: dto.sequenceNo, kind: dto.kind ?? null },
|
||||
})),
|
||||
);
|
||||
if (schedule.trainSet?.trainId) {
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
@@ -5510,6 +5823,7 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const arrivalLogRows: ScheduleWagonAdjustmentLog[] = [];
|
||||
const arrivalMovementRows: WagonMovement[] = [];
|
||||
const arrivalHistoryRows: WagonEventInput[] = [];
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
if (!slot.physicalWagonId) continue;
|
||||
const wagon = settleWagonById.get(slot.physicalWagonId);
|
||||
@@ -5533,6 +5847,32 @@ export class TrainSchedulingService {
|
||||
// Arrival fallback for a journey logged without mid-route
|
||||
// checkpoints: the REAL cut still permanently removes the wagon
|
||||
// from the built train at its cut yard.
|
||||
arrivalHistoryRows.push(
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CutAtYard,
|
||||
occurredAt: now,
|
||||
fromYardId: slot.boardYardId ?? schedule.originStationId ?? null,
|
||||
toYardId: settleYardId,
|
||||
trainId: ownerTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null,
|
||||
toValue: WagonStatus.Available,
|
||||
metadata: { permanent: true, atArrival: true },
|
||||
},
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
occurredAt: now,
|
||||
fromYardId: settleYardId,
|
||||
trainId: ownerTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
fromValue: wagon.sequenceNumber,
|
||||
reason: 'Cut from the train at its planned yard (permanent)',
|
||||
},
|
||||
);
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -5562,6 +5902,19 @@ export class TrainSchedulingService {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
arrivalHistoryRows.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: slot.boardYardId ?? schedule.originStationId ?? null,
|
||||
toYardId: settleYardId,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: (slot.allocations ?? [])[0]?.bookingId ?? null,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
metadata: { slotId: slot.id, loaded: (slot.allocations ?? []).length > 0 },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -5613,6 +5966,18 @@ export class TrainSchedulingService {
|
||||
if (!wagon) continue;
|
||||
if (wagon.currentTrainScheduleId === scheduleId) {
|
||||
// Joined during the trip, slot-less: settle at the destination.
|
||||
arrivalHistoryRows.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: coupleYardId,
|
||||
toYardId: schedule.destinationStationId ?? null,
|
||||
trainId: wagon.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
metadata: { loaded: false, coupledMidRoute: true },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -5650,6 +6015,32 @@ export class TrainSchedulingService {
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
});
|
||||
arrivalHistoryRows.push(
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
occurredAt: now,
|
||||
fromYardId: coupleYardId,
|
||||
trainId: arrivalTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: arrivalMaxSeq,
|
||||
reason: 'Planned couple joined on arrival',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
},
|
||||
{
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: coupleYardId,
|
||||
toYardId: schedule.destinationStationId ?? null,
|
||||
trainId: arrivalTrainId,
|
||||
trainScheduleId: scheduleId,
|
||||
toValue: WagonStatus.Assigned,
|
||||
metadata: { loaded: false, coupledMidRoute: true },
|
||||
},
|
||||
);
|
||||
arrivalLogRows.push(
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: scheduleId,
|
||||
@@ -5674,9 +6065,43 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Consist-only empties: coupled to the built train and bound at dispatch
|
||||
// so the checkpoint position fix moves them, but they own no slot, so the
|
||||
// per-slot settle above never sees them. Release them here or they stay
|
||||
// locked to a finished schedule and no later train can pick them up.
|
||||
// They carry no cargo, so they simply settle where the train ended up.
|
||||
const looseEmpties = await manager.getRepository(Wagon).find({
|
||||
where: { currentTrainScheduleId: scheduleId },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true, trainId: true },
|
||||
});
|
||||
arrivalHistoryRows.push(
|
||||
...looseEmpties.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.SettledOnArrival,
|
||||
occurredAt: now,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
toYardId: schedule.destinationStationId ?? null,
|
||||
trainId: w.trainId ?? null,
|
||||
trainScheduleId: scheduleId,
|
||||
metadata: { loaded: false, consistOnly: true },
|
||||
})),
|
||||
);
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.createQueryBuilder()
|
||||
.update(Wagon)
|
||||
.set({
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
})
|
||||
.where('current_train_schedule_id = :scheduleId', { scheduleId })
|
||||
.execute();
|
||||
if (arrivalLogRows.length) {
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
|
||||
}
|
||||
await this.wagonHistory?.record(manager, arrivalHistoryRows);
|
||||
if (arrivalMovementRows.length) {
|
||||
await manager.getRepository(WagonMovement).save(arrivalMovementRows);
|
||||
}
|
||||
@@ -5873,6 +6298,19 @@ export class TrainSchedulingService {
|
||||
}
|
||||
for (const wagon of schedule.trainSet?.wagons ?? []) {
|
||||
if (wagon.physicalWagonId) {
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId: wagon.physicalWagonId,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.ReturnedOnCancel,
|
||||
actorUserId: userId ?? null,
|
||||
fromYardId: wagon.physicalWagon?.currentYardId ?? null,
|
||||
toYardId: schedule.originStationId ?? null,
|
||||
trainId: wagon.physicalWagon?.trainId ?? null,
|
||||
trainScheduleId: id,
|
||||
toValue: wagon.physicalWagon?.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||
reason: dto?.reason?.trim() || 'Schedule cancelled',
|
||||
metadata: { slotId: wagon.id },
|
||||
});
|
||||
await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
@@ -5909,7 +6347,8 @@ export class TrainSchedulingService {
|
||||
const booking = await this.bookingsRepository
|
||||
.findByIdWithFiles(sb.bookingId)
|
||||
.catch(() => null);
|
||||
if (booking) this.bookingNotifier.scheduleCancelled(booking);
|
||||
// Detached above, so pass the cancelled schedule for its train/voyage numbers.
|
||||
if (booking) this.bookingNotifier.scheduleCancelled(booking, schedule);
|
||||
}
|
||||
|
||||
// Window retired (DONE) — remove the card from portal/GL lists right away.
|
||||
@@ -6849,6 +7288,15 @@ export class TrainSchedulingService {
|
||||
physicalWagonId: physical.id,
|
||||
status: 'RESERVED',
|
||||
});
|
||||
await this.wagonHistory?.record(manager, {
|
||||
wagonId: physical.id,
|
||||
wagonNumber: physical.wagonNumber,
|
||||
type: WagonEventType.PinnedToSchedule,
|
||||
trainScheduleId: scheduleId,
|
||||
trainId: builtTrainId ?? null,
|
||||
fromYardId: physical.currentYardId ?? null,
|
||||
metadata: { slotId: slot.trainSetWagonId, auto: true },
|
||||
});
|
||||
const pinnedSpans = occupiedSpans.get(physical.id) ?? [];
|
||||
pinnedSpans.push(span);
|
||||
occupiedSpans.set(physical.id, pinnedSpans);
|
||||
@@ -8925,6 +9373,21 @@ export class TrainSchedulingService {
|
||||
for (const wagon of removed) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, detachPatch);
|
||||
}
|
||||
const consistReason = (dto as { reason?: string | null }).reason?.trim() || null;
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
removed.map((wagon) => ({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: currentYardId ?? null,
|
||||
fromValue: wagon.sequenceNumber,
|
||||
reason: consistReason ?? 'Trimmed from the consist on the schedule',
|
||||
})),
|
||||
);
|
||||
if (removed.length && ownSetIds.length) {
|
||||
// This train's own pins (all its runs) on trimmed wagons are stale —
|
||||
// clear them so the freed wagon isn't still claimed by slots it left.
|
||||
@@ -8962,6 +9425,32 @@ export class TrainSchedulingService {
|
||||
// Mirror on the in-memory row — the compaction below sorts by it.
|
||||
to.sequenceNumber = from.sequenceNumber;
|
||||
await manager.getRepository(Wagon).update(from.id, detachPatch);
|
||||
await this.wagonHistory?.record(manager, [
|
||||
{
|
||||
wagonId: to.id,
|
||||
wagonNumber: to.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: to.currentYardId ?? null,
|
||||
toValue: from.sequenceNumber,
|
||||
reason: consistReason ?? `Switched in for ${from.wagonNumber}`,
|
||||
metadata: { replaced: from.wagonNumber, replacedWagonId: from.id },
|
||||
},
|
||||
{
|
||||
wagonId: from.id,
|
||||
wagonNumber: from.wagonNumber,
|
||||
type: WagonEventType.UncoupledFromTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: currentYardId ?? null,
|
||||
fromValue: from.sequenceNumber,
|
||||
reason: consistReason ?? `Switched out for ${to.wagonNumber}`,
|
||||
metadata: { replacedBy: to.wagonNumber, replacedByWagonId: to.id },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const remaining = consist.filter(
|
||||
@@ -8977,6 +9466,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
let sequence = compacted.length;
|
||||
const addedEvents: WagonEventInput[] = [];
|
||||
for (const wagon of added) {
|
||||
sequence += 1;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
@@ -8984,7 +9474,20 @@ export class TrainSchedulingService {
|
||||
sequenceNumber: sequence,
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
addedEvents.push({
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
type: WagonEventType.CoupledToTrain,
|
||||
actorUserId: userId ?? null,
|
||||
trainId: train.id,
|
||||
trainScheduleId: scheduleId,
|
||||
fromYardId: wagon.currentYardId ?? null,
|
||||
toValue: sequence,
|
||||
reason: consistReason ?? 'Added to the consist on the schedule',
|
||||
metadata: { status: { from: wagon.status, to: WagonStatus.Assigned } },
|
||||
});
|
||||
}
|
||||
await this.wagonHistory?.record(manager, addedEvents);
|
||||
|
||||
// The schedule is full when every consist wagon is allocated.
|
||||
await manager
|
||||
@@ -10513,6 +11016,8 @@ export class TrainSchedulingService {
|
||||
// without the wagons' tare. The legs tab shows this per booking.
|
||||
cargoWeightTons: sb.booking ? bookingCargoTons(sb.booking) : 0,
|
||||
status: sb.booking?.status ?? null,
|
||||
// Loadability is decided by the payment status, not `status`.
|
||||
paymentStatus: sb.booking?.paymentStatus ?? null,
|
||||
schedulingStatus: sb.booking?.schedulingStatus ?? null,
|
||||
freightType: sb.booking?.freightType ?? null,
|
||||
// Which leg of the corridor this booking rides — the workspace can't
|
||||
@@ -11508,6 +12013,30 @@ export class TrainSchedulingService {
|
||||
await allocs.update(alloc.id, { trainSetWagonId: created.id });
|
||||
}
|
||||
await slotRepo.update(source.id, emptyLoadFields);
|
||||
await this.wagonHistory?.record(manager, [
|
||||
...(source.physicalWagonId
|
||||
? [
|
||||
{
|
||||
wagonId: source.physicalWagonId,
|
||||
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedOut,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
toValue: consistWagon.wagonNumber,
|
||||
metadata: { toWagonId: consistWagon.id, allocations: sourceAllocs.length },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
wagonId: consistWagon.id,
|
||||
wagonNumber: consistWagon.wagonNumber,
|
||||
type: WagonEventType.LoadMovedIn,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
fromValue: source.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length },
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11522,6 +12051,54 @@ export class TrainSchedulingService {
|
||||
}
|
||||
await slotRepo.update(target.id, sourceLoadFields);
|
||||
await slotRepo.update(source.id, targetLoadFields);
|
||||
const moveEvents: WagonEventInput[] = [];
|
||||
if (source.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: source.physicalWagonId,
|
||||
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedOut,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
toValue: target.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { toWagonId: target.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 },
|
||||
});
|
||||
}
|
||||
if (target.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: target.physicalWagonId,
|
||||
wagonNumber: target.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedIn,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: sourceAllocs[0]?.bookingId ?? null,
|
||||
fromValue: source.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { fromWagonId: source.physicalWagonId ?? null, allocations: sourceAllocs.length, swap: targetAllocs.length > 0 },
|
||||
});
|
||||
}
|
||||
if (targetAllocs.length) {
|
||||
if (target.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: target.physicalWagonId,
|
||||
wagonNumber: target.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedOut,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: targetAllocs[0]?.bookingId ?? null,
|
||||
toValue: source.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { toWagonId: source.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true },
|
||||
});
|
||||
}
|
||||
if (source.physicalWagonId) {
|
||||
moveEvents.push({
|
||||
wagonId: source.physicalWagonId,
|
||||
wagonNumber: source.physicalWagon?.wagonNumber ?? null,
|
||||
type: WagonEventType.LoadMovedIn,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: targetAllocs[0]?.bookingId ?? null,
|
||||
fromValue: target.physicalWagon?.wagonNumber ?? null,
|
||||
metadata: { fromWagonId: target.physicalWagonId ?? null, allocations: targetAllocs.length, swap: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.wagonHistory?.record(manager, moveEvents);
|
||||
});
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
@@ -11826,7 +12403,11 @@ export class TrainSchedulingService {
|
||||
return assignability.shortage;
|
||||
}
|
||||
|
||||
/** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
|
||||
/**
|
||||
* Paid (or government) bookings that may be loaded onto wagons — excludes
|
||||
* expired / awaiting payment. "Paid" is read from the PAYMENT status only;
|
||||
* the booking status is not a reliable payment signal.
|
||||
*/
|
||||
private isReadyToLoadBooking(booking: {
|
||||
status: string;
|
||||
paymentStatus?: string | null;
|
||||
@@ -11836,7 +12417,7 @@ export class TrainSchedulingService {
|
||||
if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
|
||||
return false;
|
||||
}
|
||||
if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
|
||||
if (booking.paymentStatus === 'PAID') return true;
|
||||
if (booking.isGovernment) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -12216,6 +12797,12 @@ export class TrainSchedulingService {
|
||||
// 2. The physical wagons follow the train — the target's stay put, and
|
||||
// EVERY wagon on the source train (coupled or loose) moves across so
|
||||
// nothing strands on the deactivated train.
|
||||
const mergedFromSource = sourceTrainId
|
||||
? await manager.getRepository(Wagon).find({
|
||||
where: { trainId: sourceTrainId },
|
||||
select: { id: true, wagonNumber: true, currentYardId: true },
|
||||
})
|
||||
: [];
|
||||
if (incomingWagons.length) {
|
||||
await manager.getRepository(Wagon).update(
|
||||
{ id: In(incomingWagons.map((w) => w.id)) },
|
||||
@@ -12227,6 +12814,20 @@ export class TrainSchedulingService {
|
||||
.getRepository(Wagon)
|
||||
.update({ trainId: sourceTrainId }, { trainId: targetTrain.id });
|
||||
}
|
||||
await this.wagonHistory?.record(
|
||||
manager,
|
||||
mergedFromSource.map((w) => ({
|
||||
wagonId: w.id,
|
||||
wagonNumber: w.wagonNumber,
|
||||
type: WagonEventType.TrainMerged,
|
||||
fromYardId: w.currentYardId ?? null,
|
||||
trainId: targetTrain.id,
|
||||
trainScheduleId: schedule.id,
|
||||
fromValue: sourceTrainId,
|
||||
toValue: targetTrain.code,
|
||||
reason: `Train merged into ${targetTrain.code}`,
|
||||
})),
|
||||
);
|
||||
|
||||
// 3. Carry the target's train-set wagon rows into THIS consist, appended
|
||||
// after the existing wagons. Sequence is provisional — staff reorder
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user